Skip to content

Commit

Permalink
perf(elements): improve performance of real-time reporting (#2498)
Browse files Browse the repository at this point in the history
Instead of recalculating every time an event comes through, only react to event updates at an interval. By using the `debounceTime` function from rxjs we can tell rxjs to ignore events for a given time period, throttling the amount of times we have to recalculate and redraw the report elements.
  • Loading branch information
xandervedder committed May 11, 2023
1 parent 3bb8b12 commit 42f8dcf
Show file tree
Hide file tree
Showing 3 changed files with 63 additions and 37 deletions.
60 changes: 43 additions & 17 deletions packages/elements/src/components/app/app.component.ts
Expand Up @@ -4,7 +4,7 @@ import { MutantResult, MutationTestResult } from 'mutation-testing-report-schema
import { MetricsResult, MutantModel, TestModel, calculateMutationTestMetrics } from 'mutation-testing-metrics';
import { tailwind, globals } from '../../style';
import { locationChange$, View } from '../../lib/router';
import { Subscription } from 'rxjs';
import { Subscription, debounceTime, fromEvent } from 'rxjs';
import theme from './theme.scss';
import { createCustomEvent } from '../../lib/custom-events';
import { FileUnderTestModel, Metrics, MutationTestMetricsResult, TestFileModel, TestMetrics } from 'mutation-testing-metrics';
Expand All @@ -27,6 +27,15 @@ interface TestContext extends BaseContext {
result?: MetricsResult<TestFileModel, TestMetrics>;
}

/**
* The report needs to be able to handle realtime updates, without any constraints.
* To allow for this behaviour, we will update the `rootModel` once every 100ms.
*
* This throttling mechanism is only applied to the recalculation of the `rootModel`, since that is currently what takes
* the most time.
*/
const UPDATE_CYCLE_TIME = 100;

type Context = MutantContext | TestContext;

@customElement('mutation-test-report-app')
Expand Down Expand Up @@ -216,64 +225,81 @@ export class MutationTestReportAppComponent extends RealtimeElement {
}

private source: EventSource | undefined;
private sseSubscriptions: Set<Subscription> = new Set();
private theMutant?: MutantModel;
private theTest?: TestModel;

private initializeSse() {
if (!this.sse) {
return;
}

this.source = new EventSource(this.sse);
this.source.addEventListener('mutant-tested', (event) => {

const modifySubscription = fromEvent<MessageEvent>(this.source, 'mutant-tested').subscribe((event) => {
const newMutantData = JSON.parse(event.data as string) as Partial<MutantResult> & Pick<MutantResult, 'id' | 'status'>;
if (!this.report) {
return;
}

const theMutant = this.mutants.get(newMutantData.id);
if (theMutant === undefined) {
const mutant = this.mutants.get(newMutantData.id);
if (mutant === undefined) {
return;
}
this.theMutant = mutant;

for (const [prop, val] of Object.entries(newMutantData)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(theMutant as any)[prop] = val;
(this.theMutant as any)[prop] = val;
}

let theTest: TestModel | undefined;
if (newMutantData.killedBy) {
newMutantData.killedBy.forEach((killedByTestId) => {
const test = this.tests.get(killedByTestId)!;
theTest = test;
if (test === undefined) {
return;
}
test.addKilled(theMutant);
theMutant.addKilledBy(test);
this.theTest = test;
test.addKilled(this.theMutant!);
this.theMutant!.addKilledBy(test);
});
}

if (newMutantData.coveredBy) {
newMutantData.coveredBy.forEach((coveredByTestId) => {
const test = this.tests.get(coveredByTestId)!;
theTest = test;
if (test === undefined) {
return;
}
test.addCovered(theMutant);
theMutant.addCoveredBy(test);
this.theTest = test;
test.addCovered(this.theMutant!);
this.theMutant!.addCoveredBy(test);
});
}

theMutant.update();
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
theTest?.update();
mutantChanges.next();
});

const applySubscription = fromEvent(this.source, 'mutant-tested')
.pipe(debounceTime(UPDATE_CYCLE_TIME))
.subscribe(() => {
this.applyChanges();
});

this.sseSubscriptions.add(modifySubscription);
this.sseSubscriptions.add(applySubscription);

this.source.addEventListener('finished', () => {
this.source?.close();
this.applyChanges();
this.sseSubscriptions.forEach((s) => s.unsubscribe());
});
}

private applyChanges() {
this.theMutant?.update();
this.theTest?.update();
mutantChanges.next();
}

public disconnectedCallback() {
super.disconnectedCallback();
this.subscriptions.forEach((subscription) => subscription.unsubscribe());
Expand Down
Expand Up @@ -31,6 +31,7 @@ describe('realtime reporting', () => {
await page.whenFileReportLoaded();
client.sendMutantTested(defaultEvent);
client.sendMutantTested({ id: '1', status: 'Survived' });
client.sendFinished();

const allFilesRow = page.mutantView.resultTable().row('All files');
const attributesRow = page.mutantView.resultTable().row('Attributes');
Expand All @@ -52,6 +53,7 @@ describe('realtime reporting', () => {
expect(await mutantPending.underlineIsVisible()).to.be.true;

client.sendMutantTested(defaultEvent);
client.sendFinished();
const filter = page.mutantView.stateFilter();
await waitUntil(async () => Boolean(await filter.state(MutantStatus.Killed).isDisplayed()));
expect((await page.mutantView.mutantDots()).length).to.equal(0);
Expand Down
38 changes: 18 additions & 20 deletions packages/elements/test/unit/components/app.component.spec.ts
Expand Up @@ -301,23 +301,19 @@ describe(MutationTestReportAppComponent.name, () => {
});

describe('the `sse` property', () => {
const defaultMessage = new MessageEvent('mutation', { data: JSON.stringify({ id: '1', status: 'Killed' }) });
const defaultMessage = new MessageEvent('mutant-tested', { data: JSON.stringify({ id: '1', status: 'Killed' }) });

let eventSourceConstructorStub: sinon.SinonStub;
let eventSourceStub: sinon.SinonStubbedInstance<EventSource>;
let mutationCallback: CallableFunction;
let finishedCallback: CallableFunction;
let eventSource: EventSource;

beforeEach(() => {
eventSourceStub = sinon.createStubInstance(EventSource);
eventSourceConstructorStub = sinon.stub(window, 'EventSource').returns(eventSourceStub);
eventSourceStub.addEventListener.callsFake((type, cb) => {
if (type === 'mutant-tested') {
mutationCallback = cb as CallableFunction;
} else {
finishedCallback = cb as CallableFunction;
}
});
try {
eventSource = new EventSource('http://localhost');
} catch {
// noop
}

eventSourceConstructorStub = sinon.stub(window, 'EventSource').returns(eventSource);
sut = new CustomElementFixture('mutation-test-report-app', { autoConnect: false });
});

Expand All @@ -335,6 +331,7 @@ describe(MutationTestReportAppComponent.name, () => {

it('should initialize SSE when property is set', async () => {
// Arrange
const eventListenerStub = sinon.stub(eventSource, 'addEventListener');
sut.element.report = createReport();
sut.element.sse = 'http://localhost:8080/sse';

Expand All @@ -344,8 +341,9 @@ describe(MutationTestReportAppComponent.name, () => {

// Assert
expect(eventSourceConstructorStub.calledWith('http://localhost:8080/sse')).to.be.true;
expect(eventSourceStub.addEventListener.firstCall.args[0]).to.be.equal('mutant-tested');
expect(eventSourceStub.addEventListener.secondCall.args[0]).to.be.equal('finished');
expect(eventListenerStub.firstCall.firstArg).to.eq('mutant-tested');
expect(eventListenerStub.secondCall.firstArg).to.eq('mutant-tested');
expect(eventListenerStub.thirdCall.firstArg).to.eq('finished');
});

it('should update mutant status when SSE event comes in', async () => {
Expand All @@ -360,7 +358,7 @@ describe(MutationTestReportAppComponent.name, () => {
await sut.whenStable();

// Act
mutationCallback(defaultMessage);
eventSource.dispatchEvent(defaultMessage);

// Assert
const file = sut.element.rootModel!.systemUnderTestMetrics.childResults[0].file!;
Expand Down Expand Up @@ -393,8 +391,8 @@ describe(MutationTestReportAppComponent.name, () => {
location: { start: { line: 12, column: 1 }, end: { line: 13, column: 2 } },
mutatorName: 'test mutator',
});
const message = new MessageEvent('mutation', { data: newMutantData });
mutationCallback(message);
const message = new MessageEvent('mutant-tested', { data: newMutantData });
eventSource.dispatchEvent(message);

// Assert
const theMutant = sut.element.rootModel!.systemUnderTestMetrics.childResults[0].file!.mutants[0];
Expand All @@ -418,10 +416,10 @@ describe(MutationTestReportAppComponent.name, () => {
await sut.whenStable();

// Act
finishedCallback(message);
eventSource.dispatchEvent(message);

// Assert
expect(eventSourceStub.close.calledOnce).to.be.true;
expect(eventSource.readyState).to.be.eq(eventSource.CLOSED);
});
});

Expand Down

0 comments on commit 42f8dcf

Please sign in to comment.