Skip to content

Commit

Permalink
feat: add support for real-time reporting (#281)
Browse files Browse the repository at this point in the history
* feat: detect in-progress report

* feat: add real-time support in dashboard

* refactor(test): move `ReportsController` to the correct directory

* refactor: rename test files to be in the correct format

* test: add `RealTimeUpdatesController.spec.ts`

And various validations have been added.

* test: add various sse related tests

these were copied from the stryker-js implementation with some minor
changes. We should probably make a seperate packages for this soon.

* feat: add `constructApiUri` method

Helper method that helps with constructing the uri we need.

* feat(frontend): use correct api url

* feat: upload real-time reports to a different URL

This does not disturb the report that already exists.

* refactor: move controllers to the right package

This time it's correct...

* feat: reject in-progress reports on default URL

* refactor: use `realTime` in slug instead of a seperate if statement

* test: add `realtime=true` query parameter

* feat: saving intermediate results in azure blobs

* test: all the things

* refactor: move in-progress uploading to the correct controller

* feat: delete both blobs after finished event has been sent

* feat: use better identifier in orchestrator

* feat: validate incoming mutants

* feat: return stable report if real-time report is not available

* chore: fix naming of `MutationEventOrchestrator`

* chore: naming fixes

* feat: add ability to set CORS in `SseServer`

* fix: configuration tests

* Fix naming issues

* Fix cors setting

* Remove unnecessary validation checks

* Fix module not used

* refactor: greatly simplify working of real-time reporting

Instead of creating a new server every time, simply keep track of all
the responses and remove them when we are done with real-time reporting

* refactor: use `URL` class instead of `URLSearchParams`

* fix: `toId` now works correctly

* test: simplify `RealTimeReportsController.spec.ts`

* fix: `constructApiUri` tests

* test: remove `realTime` query string in e2e test

* feat: emit event when connection closes

* test: add e2e tests

* fix: add `js` extension to import

* test: add faulty mutants test

* fix: linting issues

* refactor: use `filter` instead of `splice`

Makes it more clear what we are actually doing.

* docs: clarify usage of `split(...)[1]`

* chore: linting issues
  • Loading branch information
xandervedder authored Oct 27, 2023
1 parent ba3b533 commit 5ef3f98
Show file tree
Hide file tree
Showing 48 changed files with 1,843 additions and 207 deletions.
12 changes: 11 additions & 1 deletion packages/common/src/Report.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { MutationTestResult } from 'mutation-testing-report-schema';
import {
MutantStatus,
MutationTestResult,
} from 'mutation-testing-report-schema';

export interface MutationScoreOnlyResult {
mutationScore: number;
Expand All @@ -7,6 +10,7 @@ export interface MutationScoreOnlyResult {
export interface ReportIdentifier {
projectName: string;
moduleName: string | undefined;
realTime?: boolean;
version: string;
}

Expand All @@ -22,3 +26,9 @@ export function isMutationTestResult(
): report is MutationTestResult {
return !!(report as MutationTestResult).files;
}

export function isPendingReport(report: MutationTestResult) {
return Object.values(report.files)
.flatMap((file) => file.mutants)
.some((mutant) => mutant.status === MutantStatus.Pending);
}
14 changes: 14 additions & 0 deletions packages/common/src/Uri.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function constructApiUri(
location: string,
slug: string,
queryParams: { module: string | undefined; realTime: string | undefined }
) {
const url = new URL(`${location}/api/reports/${slug}`);
for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined && value !== null) {
url.searchParams.append(key, value);
}
}

return url.toString();
}
1 change: 1 addition & 0 deletions packages/common/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './slug.js';
export * from './Report.js';
export * from './Logger.js';
export * from './Uri.js';
60 changes: 60 additions & 0 deletions packages/common/test/unit/Uri.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { expect } from 'chai';
import { constructApiUri } from '../../src/Uri.js';

describe(constructApiUri.name, () => {
const baseUrl = 'http://stryker-website';
it('should return the default API uri when params are empty', () => {
const uri = constructApiUri(baseUrl, 'github.com/user/project', {
module: undefined,
realTime: undefined,
});

expect(uri).to.eq(
'http://stryker-website/api/reports/github.com/user/project'
);
});

it('should also ignore null values', () => {
const uri = constructApiUri(baseUrl, 'github.com/user/project', {
module: null as unknown as undefined,
realTime: null as unknown as undefined,
});

expect(uri).to.eq(
'http://stryker-website/api/reports/github.com/user/project'
);
});

it('should return the API uri with the module as query param', () => {
const uri = constructApiUri(baseUrl, 'github.com/user/project', {
module: 'project-submodule',
realTime: undefined,
});

expect(uri).to.eq(
'http://stryker-website/api/reports/github.com/user/project?module=project-submodule'
);
});

it('should return the API uri with realTime as query param', () => {
const uri = constructApiUri(baseUrl, 'github.com/user/project', {
module: undefined,
realTime: 'true',
});

expect(uri).to.eq(
'http://stryker-website/api/reports/github.com/user/project?realTime=true'
);
});

it('should return the API uri with both the module and realTime query param', () => {
const uri = constructApiUri(baseUrl, 'github.com/user/project', {
module: 'project-submodule',
realTime: 'true',
});

expect(uri).to.eq(
'http://stryker-website/api/reports/github.com/user/project?module=project-submodule&realTime=true'
);
});
});
1 change: 1 addition & 0 deletions packages/data-access/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './models/index.js';
export * from './mappers/index.js';
export { MutationTestingReportService } from './services/MutationTestingReportService.js';
export { RealTimeMutantsBlobService } from './services/RealTimeMutantsBlobService.js';
export * from './errors/index.js';
15 changes: 9 additions & 6 deletions packages/data-access/src/mappers/MutationTestingResultMapper.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BlobServiceAsPromised } from '../services/BlobServiceAsPromised.js';
import { BlobService, Constants } from 'azure-storage';
import { encodeKey, isStorageError } from '../utils.js';
import { isStorageError, toBlobName } from '../utils.js';
import * as schema from 'mutation-testing-report-schema';
import { ReportIdentifier } from '@stryker-mutator/dashboard-common';
import { OptimisticConcurrencyError } from '../errors/index.js';
Expand Down Expand Up @@ -33,7 +33,7 @@ export class MutationTestingResultMapper {
try {
await this.blobService.createBlockBlobFromText(
MutationTestingResultMapper.CONTAINER_NAME,
this.toBlobName(id),
toBlobName(id),
JSON.stringify(result),
{
contentSettings: {
Expand All @@ -59,7 +59,7 @@ export class MutationTestingResultMapper {
public async findOne(
identifier: ReportIdentifier
): Promise<schema.MutationTestResult | null> {
const blobName = this.toBlobName(identifier);
const blobName = toBlobName(identifier);
try {
const result: schema.MutationTestResult = JSON.parse(
await this.blobService.blobToText(
Expand All @@ -81,8 +81,11 @@ export class MutationTestingResultMapper {
}
}

private toBlobName({ projectName, version, moduleName }: ReportIdentifier) {
const slug = [projectName, version, moduleName].filter(Boolean).join('/');
return encodeKey(slug);
public async delete(id: ReportIdentifier): Promise<void> {
const blobName = toBlobName(id);
await this.blobService.deleteBlobIfExists(
MutationTestingResultMapper.CONTAINER_NAME,
blobName
);
}
}
4 changes: 4 additions & 0 deletions packages/data-access/src/models/MutationTestingReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export class MutationTestingReport implements ReportIdentifier {
* For example 'schema'
*/
public moduleName: string | undefined;
/**
* Indicates whether this report is real-time.
*/
public realTime?: boolean;
public mutationScore: number;

public static createRowKey(
Expand Down
23 changes: 23 additions & 0 deletions packages/data-access/src/services/BlobServiceAsPromised.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ export class BlobServiceAsPromised {
options: BlobService.CreateBlobRequestOptions
) => Promise<BlobService.BlobResult>;
public blobToText: (container: string, blob: string) => Promise<string>;
public createAppendBlobFromText: (
container: string,
blob: string,
text: string | Buffer
) => Promise<BlobService.BlobResult>;
public appendBlockFromText: (
container: string,
blob: string,
text: string | Buffer
) => Promise<BlobService.BlobResult>;
public deleteBlobIfExists: (
container: string,
blob: string
) => Promise<unknown>;

constructor(blobService = createBlobService()) {
this.createContainerIfNotExists = promisify<
Expand All @@ -24,5 +38,14 @@ export class BlobServiceAsPromised {
blobService.createBlockBlobFromText
).bind(blobService);
this.blobToText = promisify(blobService.getBlobToText).bind(blobService);
this.createAppendBlobFromText = promisify(
blobService.createAppendBlobFromText
).bind(blobService);
this.appendBlockFromText = promisify(blobService.appendBlockFromText).bind(
blobService
);
this.deleteBlobIfExists = promisify(blobService.deleteBlobIfExists).bind(
blobService
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ export class MutationTestingReportService {
}
}

public async delete(id: ReportIdentifier): Promise<void> {
await this.resultMapper.delete(id);
}

private async insertOrMergeReport(
id: ReportIdentifier,
report: MutationTestingReport,
Expand Down
77 changes: 77 additions & 0 deletions packages/data-access/src/services/RealTimeMutantsBlobService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { ReportIdentifier } from '@stryker-mutator/dashboard-common';
import { BlobServiceAsPromised } from './BlobServiceAsPromised.js';
import { toBlobName } from '../utils.js';
import { MutantResult } from 'mutation-testing-report-schema';

// To make resource delete themselves automatically, this should be managed from within Azure:
// https://learn.microsoft.com/en-us/azure/storage/blobs/lifecycle-management-overview?tabs=azure-portal
export class RealTimeMutantsBlobService {
private static readonly CONTAINER_NAME = 'real-time-mutant-results';

#blobService: BlobServiceAsPromised;

constructor(blobService = new BlobServiceAsPromised()) {
this.#blobService = blobService;
}

public async createStorageIfNotExists() {
await this.#blobService.createContainerIfNotExists(
RealTimeMutantsBlobService.CONTAINER_NAME,
{}
);
}

public async createReport(id: ReportIdentifier) {
await this.#blobService.createAppendBlobFromText(
RealTimeMutantsBlobService.CONTAINER_NAME,
toBlobName(id),
''
);
}

public async appendToReport(
id: ReportIdentifier,
mutants: Array<Partial<MutantResult>>
) {
const blobName = toBlobName(id);
const data = mutants
.map((mutant) => `${JSON.stringify(mutant)}\n`)
.join('');

await this.#blobService.appendBlockFromText(
RealTimeMutantsBlobService.CONTAINER_NAME,
blobName,
data
);
}

public async getReport(
id: ReportIdentifier
): Promise<Array<Partial<MutantResult>>> {
const data = await this.#blobService.blobToText(
RealTimeMutantsBlobService.CONTAINER_NAME,
toBlobName(id)
);

if (data === '') {
return [];
}

return (
data
.split('\n')
// Since every line has a newline it will produce an empty string in the list.
// Remove it, so nothing breaks.
.filter((row) => row !== '')
.map((mutant) => JSON.parse(mutant))
);
}

public async delete(id: ReportIdentifier): Promise<void> {
const blobName = toBlobName(id);
this.#blobService.deleteBlobIfExists(
RealTimeMutantsBlobService.CONTAINER_NAME,
blobName
);
}
}
18 changes: 18 additions & 0 deletions packages/data-access/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ReportIdentifier } from '@stryker-mutator/dashboard-common';
import { StorageError } from 'azure-storage';

export function encodeKey(inputWithSlashes: string) {
Expand All @@ -16,3 +17,20 @@ export function isStorageError(
(maybeStorageError as StorageError).name === 'StorageError'
);
}

export function toBlobName({
projectName,
version,
moduleName,
realTime,
}: ReportIdentifier) {
const slug = [
projectName,
version,
moduleName,
realTime ? 'real-time' : realTime,
]
.filter(Boolean)
.join('/');
return encodeKey(slug);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ describe(MutationTestingResultMapper.name, () => {
blobToText: sinon.stub(),
createBlockBlobFromText: sinon.stub(),
createContainerIfNotExists: sinon.stub(),
createAppendBlobFromText: sinon.stub(),
appendBlockFromText: sinon.stub(),
deleteBlobIfExists: sinon.stub(),
};
sut = new MutationTestingResultMapper(blobMock);
});
Expand Down Expand Up @@ -45,6 +48,30 @@ describe(MutationTestingResultMapper.name, () => {
);
});

it('should encode real-time when given as option', async () => {
const result = createMutationTestResult();
await sut.insertOrReplace(
{
moduleName: 'core',
projectName: 'project',
version: 'version',
realTime: true,
},
result
);
expect(blobMock.createBlockBlobFromText).calledWith(
'mutation-testing-report',
'project;version;core;real-time',
JSON.stringify(result),
{
contentSettings: {
contentType: 'application/json',
contentEncoding: 'utf8',
},
}
);
});

it('should throw OptimisticConcurrencyError "BlobHasBeenModified" is thrown', async () => {
blobMock.createBlockBlobFromText.rejects(
new StorageError('BlobHasBeenModified')
Expand Down Expand Up @@ -86,4 +113,22 @@ describe(MutationTestingResultMapper.name, () => {
expect(actual).null;
});
});

describe('delete', () => {
it('should delete the blob', () => {
const identifier = {
moduleName: 'core',
projectName: 'project',
version: 'version',
};

sut.delete(identifier);

expect(blobMock.deleteBlobIfExists.calledOnce).to.be.true;
expect(blobMock.deleteBlobIfExists).calledWith(
'mutation-testing-report',
'project;version;core'
);
});
});
});
Loading

0 comments on commit 5ef3f98

Please sign in to comment.