Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(elements): realtime reporting #2453

Merged
merged 23 commits into from
Apr 28, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
239de61
feat: implement realtime reporting
xandervedder Apr 7, 2023
319f333
fix: use dictionary for massive performance gains
xandervedder Apr 7, 2023
e81a1b7
fix: filters not applied during re-render
xandervedder Apr 11, 2023
96673fe
fix: keep drawer open when realtime update comes through
xandervedder Apr 17, 2023
0356cce
fix: resolve linter issues
xandervedder Apr 17, 2023
10f7b67
test: keep state of file when update comes in
xandervedder Apr 18, 2023
115cdcd
test: add unit tests for SSE functionality
xandervedder Apr 20, 2023
76ae4c2
test: add integration test for realtime reporting
xandervedder Apr 20, 2023
1b006d7
fix: linting issue
xandervedder Apr 20, 2023
d699e83
fix: demo realtime updates in testResources
xandervedder Apr 21, 2023
46d36bf
temp: broken stuff
xandervedder Apr 21, 2023
f44b19b
fix: revert updating of rootModel
xandervedder Apr 22, 2023
e4400a6
fix: repair broken tests
xandervedder Apr 22, 2023
f847a7c
fix: remove unnecessary property
xandervedder Apr 22, 2023
530a0ad
fix: linting issues
xandervedder Apr 23, 2023
20b88c1
fix: working integration tests
xandervedder Apr 23, 2023
c77876f
fix: make mocha exit when tests are pending
xandervedder Apr 23, 2023
d8e6772
feat: add reactive-element.ts
xandervedder Apr 25, 2023
a61e74f
feat: allow `it.only` locally but not in pipeline
xandervedder Apr 26, 2023
da8a403
feat: only update metrics instead of entire tree structure
xandervedder Apr 26, 2023
10589f8
fix: use actual private to make tests pass
xandervedder Apr 26, 2023
cc4501b
chore: add todo in `mutant-model`
xandervedder Apr 28, 2023
6b1d863
fix: remove `_` from variable names
xandervedder Apr 28, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
74 changes: 72 additions & 2 deletions packages/elements/src/components/app/app.component.ts
@@ -1,7 +1,7 @@
import { LitElement, html, PropertyValues, unsafeCSS, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { MutationTestResult } from 'mutation-testing-report-schema/api';
import { MetricsResult, calculateMutationTestMetrics } from 'mutation-testing-metrics';
import { MutantResult, MutationTestResult } from 'mutation-testing-report-schema/api';
import { MetricsResult, MutantModel, calculateMutationTestMetrics } from 'mutation-testing-metrics';
import { tailwind, globals } from '../../style';
import { locationChange$, View } from '../../lib/router';
import { Subscription } from 'rxjs';
Expand Down Expand Up @@ -38,6 +38,9 @@ export class MutationTestReportAppComponent extends LitElement {
@property()
public src: string | undefined;

@property()
public sse: string | undefined;

@property({ attribute: false })
public errorMessage: string | undefined;

Expand Down Expand Up @@ -98,6 +101,7 @@ export class MutationTestReportAppComponent extends LitElement {

if (this.report) {
if (changedProperties.has('report')) {
this.prepareMutantDatastructure();
this.updateModel(this.report);
}
if (changedProperties.has('path') || changedProperties.has('report')) {
Expand All @@ -110,6 +114,19 @@ export class MutationTestReportAppComponent extends LitElement {
}
}

private mutants: Map<string, MutantResult> = new Map();

private prepareMutantDatastructure() {
if (!this.report) {
return;
}

const allMutants = Object.values(this.report.files).flatMap((file) => file.mutants);
allMutants.forEach((mutant) => {
this.mutants.set(mutant.id, mutant);
});
}

public updated(changedProperties: PropertyValues) {
if (changedProperties.has('theme') && this.theme) {
this.dispatchEvent(
Expand Down Expand Up @@ -177,9 +194,62 @@ export class MutationTestReportAppComponent extends LitElement {
public static styles = [globals, unsafeCSS(theme), tailwind];

public readonly subscriptions: Subscription[] = [];

public connectedCallback() {
super.connectedCallback();
this.subscriptions.push(locationChange$.subscribe((path) => (this.path = path)));
this.initializeSSE();
}

private source: EventSource | undefined;

private initializeSSE() {
xandervedder marked this conversation as resolved.
Show resolved Hide resolved
if (!this.sse) {
return;
}

this.source = new EventSource(this.sse);
this.source.addEventListener('mutation', (event) => {
const newMutantData = JSON.parse(event.data as string) as Partial<MutantModel> & Pick<MutantModel, 'id'> & Pick<MutantModel, 'status'>;
if (!this.report) {
return;
}

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

for (const prop in newMutantData) {
theMutant[prop] = newMutantData[prop];
}

this.scheduleRender();
});
this.source.addEventListener('finished', () => {
this.source?.close();
this.scheduleRender();
});
}

private renderScheduled = false;
private updateTimeout = 1000 / 60;

private scheduleRender() {
if (this.renderScheduled) {
return;
}

this.renderScheduled = true;
setTimeout(() => {
if (!this.report) {
return;
}

this.updateModel(this.report);
this.updateContext();
this.renderScheduled = false;
}, this.updateTimeout);
}

public disconnectedCallback() {
Expand Down
20 changes: 18 additions & 2 deletions packages/elements/src/components/file/file.component.ts
Expand Up @@ -137,6 +137,7 @@ export class FileComponent extends LitElement {

private toggleMutant(mutant: MutantModel) {
this.removeCurrentDiff();

if (this.selectedMutant === mutant) {
this.selectedMutant = undefined;
this.dispatchEvent(createCustomEvent('mutant-selected', { selected: false, mutant }));
Expand Down Expand Up @@ -175,7 +176,7 @@ export class FileComponent extends LitElement {
]
.filter((status) => this.model.mutants.some((mutant) => mutant.status === status))
.map((status) => ({
enabled: [MutantStatus.Survived, MutantStatus.NoCoverage, MutantStatus.Timeout].includes(status),
enabled: [...this.selectedMutantStates, MutantStatus.Survived, MutantStatus.NoCoverage, MutantStatus.Timeout].includes(status),
count: this.model.mutants.filter((m) => m.status === status).length,
status,
label: html`${getEmojiForStatus(status)} ${status}`,
Expand Down Expand Up @@ -216,13 +217,28 @@ export class FileComponent extends LitElement {
this.mutants = this.model.mutants
.filter((mutant) => this.selectedMutantStates.includes(mutant.status))
.sort((m1, m2) => (gte(m1.location.start, m2.location.start) ? 1 : -1));
if (this.selectedMutant && !this.mutants.includes(this.selectedMutant)) {

if (
this.selectedMutant &&
!this.mutants.includes(this.selectedMutant) &&
changes.has('selectedMutantStates') &&
// This extra check is to allow mutants that have been opened before, to stay open when a realtime update comes through
this.selectedMutantsHaveChanged(changes.get('selectedMutantStates'))
xandervedder marked this conversation as resolved.
Show resolved Hide resolved
) {
this.toggleMutant(this.selectedMutant);
}
}
super.update(changes);
}

private selectedMutantsHaveChanged(changedMutantStates: MutantStatus[]): boolean {
if (changedMutantStates.length !== this.selectedMutantStates.length) {
return true;
}

return !changedMutantStates.every((state, index) => this.selectedMutantStates[index] === state);
}

private highlightedReplacementRows(mutant: MutantModel): string {
const mutatedLines = mutant.getMutatedLines().trimEnd();
const originalLines = mutant.getOriginalLines().trimEnd();
Expand Down
47 changes: 47 additions & 0 deletions packages/elements/test/integration/lib/SseServer.ts
@@ -0,0 +1,47 @@
import http, { Server } from 'http';

export class SseTestServer {
private server: Server | undefined;
private response: http.ServerResponse | undefined;

public start() {
this.server = http.createServer((req, res) => {
if (req.url === '/') {
this.response = res;
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
}
});
this.server.listen();
}

public send(event: { name: string; data: object }) {
xandervedder marked this conversation as resolved.
Show resolved Hide resolved
if (this.response === undefined) {
return;
}

this.response.write(`event: ${event.name}\n`);
this.response.write(`data: ${JSON.stringify(event.data)}\n\n`);
}

public get port() {
if (this.server === undefined) {
return null;
}

const address = this.server.address();
if (address === null) {
return null;
}

if (typeof address === 'string') {
return null;
}

return address.port;
}
}
74 changes: 74 additions & 0 deletions packages/elements/test/integration/realtime-reporting.it.spec.ts
@@ -0,0 +1,74 @@
import { expect } from 'chai';
import { SseTestServer } from './lib/SseServer';
import { getCurrent } from './lib/browser';
import { ReportPage } from './po/ReportPage';
import { sleep } from './lib/helpers';
import { MutantStatus } from 'mutation-testing-report-schema';

describe('realtime reporting', () => {
const server: SseTestServer = new SseTestServer();
const defaultEvent = { name: 'mutation', data: { id: '0', status: 'Killed' } };

before(() => {
server.start();
});

let page: ReportPage;
let port: number;

beforeEach(async () => {
port = server.port ?? 0;
page = new ReportPage(getCurrent());
await page.navigateTo(`realtime-reporting-example/?port=${port}`);
await page.whenFileReportLoaded();
});

describe('when navigating to the overview page', () => {
it('should update the mutation testing metrics', async () => {
const allFilesRow = page.mutantView.resultTable().row('All files');
const attributesRow = page.mutantView.resultTable().row('Attributes');
const wrappitContextRow = page.mutantView.resultTable().row('WrappitContext.cs');

server.send(defaultEvent);
server.send({ name: 'mutation', data: { id: '1', status: 'Survived' } });
await sleep(25);

expect(await allFilesRow.progressBar().percentageText()).to.eq('50.00');
expect(await attributesRow.progressBar().percentageText()).to.eq('100.00');
expect(await wrappitContextRow.progressBar().percentageText()).to.eq('0.00');
});
});

describe('when navigating to a file with 1 mutant', () => {
beforeEach(async () => {
await page.navigateTo(`realtime-reporting-example/?port=${port}#mutant/Attributes/HandleAttribute.cs/`);
});

it('should update the state of a mutant', async () => {
expect(await page.mutantView.mutantDots()).to.be.lengthOf(1);
const mutantPending = page.mutantView.mutantMarker('0');
expect(await mutantPending.underlineIsVisible()).to.be.true;

server.send(defaultEvent);
await sleep(25);
xandervedder marked this conversation as resolved.
Show resolved Hide resolved

expect(await page.mutantView.mutantDots()).to.be.lengthOf(0);
const filter = page.mutantView.stateFilter();
await filter.state(MutantStatus.Killed).click();
expect(await page.mutantView.mutantDots()).to.be.lengthOf(1);
});

it('should keep the drawer open if it has been selected while an update comes through', async () => {
const mutant = page.mutantView.mutantDot('0');
const drawer = page.mutantView.mutantDrawer();
await mutant.toggle();
await drawer.whenHalfOpen();
expect(await drawer.isHalfOpen()).to.be.true;

server.send(defaultEvent);
await sleep(25);

expect(await drawer.isHalfOpen()).to.be.true;
});
});
});