Skip to content

Commit

Permalink
chore: add support for cleaning up old test data
Browse files Browse the repository at this point in the history
chore: add support for deleting running tests
chore: add delete confirmation
  • Loading branch information
rorlic committed Mar 27, 2024
1 parent 82b4a3c commit 7c32d46
Show file tree
Hide file tree
Showing 3 changed files with 109 additions and 82 deletions.
141 changes: 85 additions & 56 deletions src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as cp from 'node:child_process';
import { XMLParser } from "fast-xml-parser";
import { read } from 'read-last-lines';

import { JMeterTest, TestRun, TestRunDatabase, TestRunStatus } from "./interfaces";
import { JMeterTest, TestRun, TestRunStatus } from "./interfaces";

export const metadataName = 'metadata.json';
const testName = 'test.jmx';
Expand All @@ -35,37 +35,42 @@ const overviewTemplate = '<!DOCTYPE html><html>\
{{/tests}}\
</body></html>';


interface Test {
run: TestRun;
process: cp.ChildProcessWithoutNullStreams | undefined;
}

interface TestRunDatabase {
[key: string]: Test
}

export class Controller {

private _testRunsById: TestRunDatabase = {};
private _testsById: TestRunDatabase = {};
private _testParser = new XMLParser({ stopNodes: ['jmeterTestPlan.hashTree.hashTree'], ignoreAttributes: false, attributeNamePrefix: '_' });

private get _testRuns() {
return Object.values(this._testRunsById);
private get _tests() {
return Object.values(this._testsById);
}

private _getTestRun(id: string) {
return this._testRunsById[id];
private _getTest(id: string) {
return this._testsById[id];
}

private _upsertTestRun(run: TestRun) {
this._testRunsById[run.id] = run;
return run;
}

private _deleteTestRun(run: TestRun) {
delete this._testRunsById[run.id];
return run;
private _upsertTest(test: Test) {
this._testsById[test.run.id] = test;
return test;
}

private async _getSubDirectories(source: string) {
return (await fsp.readdir(source, { withFileTypes: true })).filter(x => x.isDirectory()).map(x => x.name);
}

private _cancelRunningTests() {
Object.values(this._testRunsById).forEach(run => {
if (run.status === TestRunStatus.running) {
run.status = TestRunStatus.cancelled;
Object.values(this._testsById).forEach(test => {
if (test.run.status === TestRunStatus.running) {
test.run.status = TestRunStatus.cancelled;
}
});
}
Expand All @@ -75,20 +80,6 @@ export class Controller {
this._write(metadata, JSON.stringify(run));
}

private _purgeTestRun(run: TestRun) {
const id = run.id;
if (run.status === TestRunStatus.running) {
return `Test ${id} is still running.`
}

const folder = path.join(this._baseFolder, id);
fs.rmSync(folder, { recursive: true, force: true });
const logs = path.join(this._logFolder, `${id}.log`);
fs.rmSync(logs);
this._deleteTestRun(run);
return '';
}

private _read(fullPathName: string) {
return fs.readFileSync(fullPathName, { encoding: 'utf8' });
}
Expand All @@ -98,13 +89,14 @@ export class Controller {
}

constructor(
private _baseFolder: string,
private _baseUrl: string,
private _refreshTimeInSeconds: number,
private _logFolder: string) { }
private _baseFolder: string,
private _baseUrl: string,
private _refreshTimeInSeconds: number,
private _logFolder: string,
private _silent: boolean) { }

public get runningCount(): number {
return Object.values(this._testRunsById).filter(x => x.status == TestRunStatus.running).length;
return Object.values(this._testsById).filter(x => x.run.status == TestRunStatus.running).length;
}

public async importTestRuns() {
Expand All @@ -116,7 +108,7 @@ export class Controller {
try {
const content = fs.readFileSync(fd, { encoding: 'utf8' });
const run = JSON.parse(content);
this._upsertTestRun(run);
this._upsertTest({ run: run, process: undefined } as Test);
} finally {
fs.closeSync(fd);
}
Expand All @@ -127,45 +119,80 @@ export class Controller {

public async exportTestRuns() {
this._cancelRunningTests();
this._testRuns.forEach(run => this._writeMetadata(run));
this._tests.forEach(test => this._writeMetadata(test.run));
}

public testRunExists(id: string): boolean {
return this._getTestRun(id) != undefined;
public testExists(id: string): boolean {
return this._getTest(id) != undefined;
}

public deleteTestRun(id: string): string {
const run = this._getTestRun(id);
if (!run) throw new Error(`Test ${id} does not exist.`);
return this._purgeTestRun(run);
public testRunning(id: string): boolean {
const test = this._getTest(id);
return !!test && test.run.status === TestRunStatus.running;
}

public deleteAllTestRuns(): string[] {
return this._testRuns.map(x => this._purgeTestRun(x));
public deleteTest(id: string) {
const testRunData = path.join(this._baseFolder, id);
const logFile = path.join(this._logFolder, `${id}.log`);

const exists = this.testExists(id);
if (exists) {
if (this.testRunning(id)) {
console.warn(`[WARN] Test ${id} is running...`);
const process = this._testsById[id]?.process;
console.warn(`[WARN] Killing pid ${process?.pid}...`);
const killed = process?.kill;
console.warn(killed ? `[WARN] Test ${id} was cancelled.` : `Failed to kill test ${id} (pid: ${process?.pid}).`);
}
delete this._testsById[id];
} else {
console.warn(`[WARN] Test ${id} does not exist (in memory DB) but trying to remove test data (${testRunData}) and log file (${logFile}).`);
}

const testDataExists = fs.existsSync(testRunData);
if (testDataExists) {
if (!this._silent) console.info(`[INFO] Deleting test data at ${testRunData}...`);
fs.rmSync(testRunData, { recursive: true, force: true });
console.warn(`[WARN] Deleted test data at ${testRunData}.`);
}

const logFileExists = fs.existsSync(logFile);
if (logFileExists) {
if (!this._silent) console.info(`[INFO] Deleting log file at ${logFile}...`);
fs.rmSync(logFile);
console.warn(`[WARN] Deleted log file at ${logFile}.`);
}

return exists || testDataExists || logFileExists;
}

public deleteAllTestRuns() {
this._tests.map(x => this.deleteTest(x.run.id));
}

public async getTestRunStatus(id: string, limit: number = 1000) {
const run = this._getTestRun(id);
if (!run) throw new Error(`Test ${id} does not exist.`);
const test = this._getTest(id);
if (!test) throw new Error(`Test ${id} does not exist.`);

const logs = path.join(this._logFolder, `${id}.log`);
const output = limit ? await read(logs, limit) : this._read(logs);
const data = {
...run,
refresh: run.status === TestRunStatus.running ? this._refreshTimeInSeconds : false,
...test.run,
refresh: test.run.status === TestRunStatus.running ? this._refreshTimeInSeconds : false,
output: output,
};
return Mustache.render(statusTemplate, data);
}

public getTestRunsOverview() {
const testRuns = this._testRuns;
const tests = this._tests;

if (!testRuns.length) {
if (!tests.length) {
return Mustache.render(noTestsFoundTemplate, { refresh: this._refreshTimeInSeconds });
}

const runs = testRuns
const runs = tests
.map(x => x.run)
.sort((f, s) => Date.parse(f.timestamp) - Date.parse(s.timestamp))
.map(test => {
switch (test.status) {
Expand Down Expand Up @@ -204,20 +231,22 @@ export class Controller {
const parsed = this._testParser.parse(body) as JMeterTest;
const timestamp = new Date().toISOString();

const jmeter = cp.spawn('jmeter', ['-n', '-t', `${testName}`, '-l', `${reportName}`, '-e', '-o', `${resultsFolder}`], { cwd: folder });
const run = {
id: id,
name: parsed.jmeterTestPlan.hashTree.TestPlan._testname,
category: category,
timestamp: timestamp,
status: TestRunStatus.running
} as TestRun;
this._writeMetadata(this._upsertTestRun(run));
const test = this._upsertTest({ run: run, process: jmeter } as Test);

const jmeter = cp.spawn('jmeter', ['-n', '-t', `${testName}`, '-l', `${reportName}`, '-e', '-o', `${resultsFolder}`], { cwd: folder });
this._writeMetadata(test.run);

jmeter.on('close', (code) => {
try {
this._writeMetadata(this._upsertTestRun({ ...run, status: TestRunStatus.done, code: code }));
const updatedTest = { run: { ...run, status: TestRunStatus.done, code: code }, process: jmeter } as Test;
this._writeMetadata(this._upsertTest(updatedTest).run);
} catch (error) {
console.error('Failed to write metadata because: ', error);
}
Expand Down
6 changes: 1 addition & 5 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,5 @@ export interface TestRun {
name: string;
timestamp: string;
status: TestRunStatus;
code: number | null;
}

export interface TestRunDatabase {
[key: string]: TestRun
code: number | undefined;
}
44 changes: 23 additions & 21 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ if (!fs.existsSync(logFolder)) {
logFolder = fs.realpathSync(logFolder);
console.info("Storing logs in: ", logFolder);

const controller = new Controller(baseFolder, baseUrl, refreshTimeInSeconds, logFolder);
const controller = new Controller(baseFolder, baseUrl, refreshTimeInSeconds, logFolder, silent);

function checkApiKey(request: any, apiKey: string): boolean {
return !apiKey || request.headers['x-api-key'] === apiKey;
Expand Down Expand Up @@ -89,7 +89,7 @@ server.post('/', { schema: { querystring: { category: { type: 'string' } } } },
if (controller.runningCount >= maxRunning) {
return reply.status(503)
.header('content-type', 'text/plain')
.send(`Cannot start new test run as the maximum (${maxRunning}) simultaneous tests runs are currently running. Try again later.`);
.send(`Cannot start new test run as the maximum (${maxRunning}) simultaneous tests runs are currently running. Try again later.\n`);
}

const parameters = request.query as { category?: string };
Expand All @@ -112,7 +112,7 @@ server.get('/', async (request, reply) => {
const body = controller.getTestRunsOverview();
reply.header('content-type', 'text/html').send(body);
} catch (error) {
reply.send({ msg: 'Cannot display test runs overview', error: error });
reply.send({ msg: 'Cannot display test runs overview\n', error: error });
}
});

Expand All @@ -125,55 +125,57 @@ server.get('/:id', { schema: { querystring: { limit: { type: 'number' } } } }, a
const { id } = request.params as { id: string };

try {
if (controller.testRunExists(id)) {
if (controller.testExists(id)) {
const body = await controller.getTestRunStatus(id, parameters.limit);
reply.header('content-type', 'text/html').send(body);
} else {
reply.status(404).send('');
}
} catch (error) {
reply.send({ msg: `Cannot display status for test run ${id}`, error: error });
reply.send({ msg: `Cannot display status for test run ${id}\n`, error: error });
}
});

server.delete('/', async (request, reply) => {
server.delete('/', { schema: { querystring: { confirm: { type: 'boolean' } } } }, async (request, reply) => {
if (!checkApiKey(request, apiKeyDeleteTest)) {
return reply.status(401).send('');
}

const parameters = request.query as { confirm?: boolean };

try {
const responses = controller.deleteAllTestRuns().filter(x => !!x);
if (responses.length > 0) {
const response = responses.join('\n');
reply.status(405).send(response);
if (controller.runningCount > 0 && !parameters.confirm) {
reply.status(405).send("Cannot delete all tests as some are still running.\nHint:pass query parameter '?confirm=true'.\n");
} else {
reply.send('All tests deleted');
controller.deleteAllTestRuns();
reply.send('All tests deleted\n');
}
} catch (error) {
reply.send({ msg: 'Cannot delete all tests', error: error });
reply.send({ msg: 'Cannot delete all tests\n', error: error });
}
});

server.delete('/:id', async (request, reply) => {
server.delete('/:id', { schema: { querystring: { confirm: { type: 'boolean' } } } }, async (request, reply) => {
if (!checkApiKey(request, apiKeyDeleteTest)) {
return reply.status(401).send('');
}

const { id } = request.params as { id: string };
const parameters = request.query as { confirm?: boolean };

try {
if (controller.testRunExists(id)) {
const response = controller.deleteTestRun(id);
if (response) {
reply.status(405).send(response);
if (controller.testRunning(id) && !parameters.confirm) {
reply.status(405).send(`Test ${id} is still running.\nHint:pass query parameter '?confirm=true'.\n`);
} else {
const deleted = controller.deleteTest(id);
if (deleted) {
reply.send(`Test ${id} deleted\n`);
} else {
reply.send(`Test ${id} deleted`);
reply.status(404).send(`Test ${id} not found\n`);
}
} else {
reply.status(404).send('');
}
} catch (error) {
reply.send({ msg: `Cannot delete test ${id}`, error: error });
reply.send({ msg: `Cannot delete test ${id}\n`, error: error });
}
});

Expand Down

0 comments on commit 7c32d46

Please sign in to comment.