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 17 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/elements/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"test": "npm run test:unit && npm run test:integration",
"test:unit": "karma start --single-run",
"test:unit:debug": "karma start --browsers ChromeHeadlessDebug",
"test:integration": "mocha --forbid-only --config ./test/integration/.mocharc.jsonc",
"test:integration": "mocha --forbid-only --exit --config ./test/integration/.mocharc.jsonc",
"test:integration:update": "cross-env UPDATE_ALL_SCREENSHOTS=true HEADLESS=true mocha --config ./test/integration/.mocharc.jsonc",
"test:integration:headless": "cross-env HEADLESS=true mocha --config ./test/integration/.mocharc.jsonc",
"postpublish": "PUBLISH_ELEMENTS=true ../metrics-scala/npmProjPublish.sh",
Expand Down
50 changes: 49 additions & 1 deletion packages/elements/src/components/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { LitElement, html, PropertyValues, unsafeCSS, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { MutationTestResult } from 'mutation-testing-report-schema/api';
import { MutantResult, MutationTestResult } from 'mutation-testing-report-schema/api';
import { MetricsResult, calculateMutationTestMetrics } from 'mutation-testing-metrics';
import { tailwind, globals } from '../../style';
import { locationChange$, View } from '../../lib/router';
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 @@ -110,6 +113,8 @@ export class MutationTestReportAppComponent extends LitElement {
}
}

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

public updated(changedProperties: PropertyValues) {
if (changedProperties.has('theme') && this.theme) {
this.dispatchEvent(
Expand All @@ -136,6 +141,15 @@ export class MutationTestReportAppComponent extends LitElement {
}

private updateModel(report: MutationTestResult) {
if (!this.report) {
return;
}

this.mutants.clear();
Object.values(this.report.files)
.flatMap((file) => file.mutants)
.forEach((mutant) => this.mutants.set(mutant.id, mutant));

this.rootModel = calculateMutationTestMetrics(report);
}

Expand Down Expand Up @@ -177,9 +191,43 @@ 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() {
if (!this.sse) {
return;
}

this.source = new EventSource(this.sse);
this.source.addEventListener('mutant-tested', (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) {
return;
}

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

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

public disconnectedCallback() {
Expand Down
35 changes: 33 additions & 2 deletions packages/elements/src/components/file/file.component.ts
Original file line number Diff line number Diff line change
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 @@ -211,18 +212,48 @@ export class FileComponent extends LitElement {
}
}
});

if (this.selectedMutant !== undefined) {
this.dispatchEventIfMutantIsUpdated();
}
}
if ((changes.has('model') && this.model) || changes.has('selectedMutantStates')) {
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'))
) {
this.toggleMutant(this.selectedMutant);
}
}
super.update(changes);
}

private dispatchEventIfMutantIsUpdated(): void {
const theSameMutant = this.model.mutants.find((mutant) => mutant.id === this.selectedMutant?.id);
// TODO: deep eq?
if (theSameMutant === undefined || this.selectedMutant?.status === theSameMutant.status) {
return;
}

this.selectedMutant = theSameMutant;
this.dispatchEvent(createCustomEvent('mutant-selected', { selected: true, mutant: theSameMutant }));
}

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
73 changes: 73 additions & 0 deletions packages/elements/test/integration/lib/SseServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import EventEmitter from 'events';
import express from 'express';
import type { ServerResponse } from 'http';
import type { AddressInfo } from 'net';

export class SseTestServer extends EventEmitter {
#app;

constructor() {
super();
this.#app = express();
this.#app.use(this.middleware);
}

public on(eventType: 'client-connected' | 'client-disconnected', callback: (client: ReportingClient) => void): this {
return super.on(eventType, callback);
}
public emit(eventType: 'client-connected' | 'client-disconnected', client: ReportingClient): boolean {
return super.emit(eventType, client);
}

public middleware = (_: unknown, res: import('express').Response) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
const client = new ReportingClient(res);
res.on('close', () => this.emit('client-disconnected', client));
this.emit('client-connected', client);
};

public start(): Promise<number> {
return new Promise((resolve, reject) => {
const server = this.#app.listen();

server.on('error', (e) => {
console.log(e);
reject(e);
});
server.on('listening', () => {
const port = (server.address() as AddressInfo).port;
resolve(port);
});
});
}
}

export class ReportingClient {
constructor(private readonly response: ServerResponse) {}

public sendMutantTested(data: object) {
this.send({ name: 'mutant-tested', data: data });
}

public sendFinished() {
this.send({ name: 'finished', data: {} });
}

private send(event: { name: string; data: object }) {
if (this.response === undefined) {
return;
}

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

public disconnect() {
this.response.destroy();
}
}
74 changes: 74 additions & 0 deletions packages/elements/test/integration/realtime-reporting.it.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { expect } from 'chai';
import { SseTestServer } from './lib/SseServer';
import { getCurrent } from './lib/browser';
import { ReportPage } from './po/ReportPage';
import { ReportingClient } from './lib/SseServer';
import { sleep } from './lib/helpers';
import { MutantStatus } from 'mutation-testing-report-schema';

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

let port: number;
let page: ReportPage;
let client: ReportingClient;

beforeEach(async () => {
port = await server.start();
page = new ReportPage(getCurrent());
});

describe('when navigating to the overview page', () => {
it('should update the mutation testing metrics', async () => {
server.on('client-connected', (c) => (client = c));

await page.navigateTo(`realtime-reporting-example/?port=${port}`);
await page.whenFileReportLoaded();
client.sendMutantTested(defaultEvent);
client.sendMutantTested({ id: '1', status: 'Survived' });

const allFilesRow = page.mutantView.resultTable().row('All files');
const attributesRow = page.mutantView.resultTable().row('Attributes');
const wrappitContextRow = page.mutantView.resultTable().row('WrappitContext.cs');

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

describe('when navigating to a file with 1 mutant', () => {
it('should update the state of a mutant', async () => {
server.on('client-connected', (c) => (client = c));
await page.navigateTo(`realtime-reporting-example/?port=${port}#mutant/Attributes/HandleAttribute.cs/`);
await page.whenFileReportLoaded();
expect((await page.mutantView.mutantDots()).length).to.equal(1);
const mutantPending = page.mutantView.mutantMarker('0');
expect(await mutantPending.underlineIsVisible()).to.be.true;

client.sendMutantTested(defaultEvent);
await sleep(25);

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

it('should keep the drawer open if it has been selected while an update comes through', async () => {
server.on('client-connected', (c) => (client = c));
await page.navigateTo(`realtime-reporting-example/?port=${port}#mutant/Attributes/HandleAttribute.cs/`);

const mutant = page.mutantView.mutantDot('0');
const drawer = page.mutantView.mutantDrawer();
await mutant.toggle();
await drawer.whenHalfOpen();
expect(await drawer.isHalfOpen()).to.be.true;

client.sendMutantTested(defaultEvent);

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