slither
Version:
Quick testing for short scripts
520 lines (431 loc) • 14.5 kB
text/typescript
import * as commander from "commander";
import * as cp from "mz/child_process";
import * as fs from "mz/fs";
import truncate = require("cli-truncate");
import width = require("string-width");
import rusage = require("qrusage");
require("keypress")(process.stdin);
import checkers from "./checkers";
const HIDE_CURSOR = "\x1b[?25l";
const SHOW_CURSOR = "\x1b[?25h";
const UP = (n: number) => `\x1b[${n}A`;
const DOWN = (n: number) => `\x1b[${n}B`;
const RED = "\x1b[31m";
const GREEN = "\x1b[32m";
const YELLOW = "\x1b[33m";
const BLUE = "\x1b[34m";
const WHITE = "\x1b[39m";
const PURPLE = "\x1b[35m";
const CYAN = "\x1b[36m";
const GRAY = "\x1b[90m";
const BOLD = "\x1b[1m";
const CLEAR = "\x1b[0m";
const ERASE_SCREEN = "\x1b[2J";
const RESET_CURSOR = "\x1b[0,0H";
const SNEK = "🐍 ";
const OK = "✓";
const WRONG_ANSWER = "✗";
const WAITING = "•";
const RUNNING = "•";
const TIME_LIMIT_EXCEEDED = "t";
const RUNTIME_ERROR = "!";
const MEMORY_LIMIT_EXCEEDED = "m";
function parseTests(val: string) {
let parts = val.split(",");
let tests = [];
for (let part of parts) {
if (part.indexOf("-") >= 0) { // range
let [aStr, bStr] = part.split("-");
let [a, b] = [parseInt(aStr), parseInt(bStr)];
for (let i = a; i <= b; i++) {
tests.push(i);
}
} else { // single number
tests.push(parseInt(part));
}
}
return tests;
}
function exit(message: string): any {
console.error(message);
process.exit(1);
}
let name: string;
commander
.arguments("<set>")
.option("-t, --tests [tests]", "Which tests to run", parseTests)
.option("-i, --inspect", "Inspect tests after running")
.option("-r, --raw", "Get raw output from a single test")
.action((set: string) => {
name = set;
})
.parse(process.argv);
if (name === undefined) {
console.error(`${RED}Missing test set name.${CLEAR}`);
} else {
fs.readFile(".slither/config.json")
.catch(() => exit(`${RED}No Slither configuration found in the current directory. Run ${WHITE}${BOLD}slither init${RED}${CLEAR}${RED} first.${CLEAR}`))
.then((data: Buffer) => {
let config = JSON.parse(data.toString()) as Config;
let testset: Testset = config[name];
if (!testset) {
return Promise.reject(`${RED}No testset with the name "${name}" was found.${CLEAR}`);
}
let tests = (commander as any).tests;
if (Array.isArray(tests)) {
return { testset, tests };
} else {
return getAllTests(name).then((tests) => ({ testset, tests }));
}
})
.catch(exit)
.then(({ testset, tests }: { testset: Testset, tests: number[] }) => {
if ((commander as any).raw && tests.length != 1) {
return Promise.reject(`${BOLD}${RED}Error: -r/--raw can only be used with a single test${CLEAR}${SHOW_CURSOR}`);
} else if ((commander as any).raw && (commander as any).inspect) {
return Promise.reject(`${BOLD}${RED}Error: -r/--raw can't be used with -i/--inspect${SHOW_CURSOR}`);
}
return Promise.resolve({ testset, tests });
})
.catch(exit)
.then(({ testset, tests }: { testset: Testset, tests: number[] }) => {
return Promise.all([
testset,
tests,
exec(testset.scripts.compile)
]) as Promise<[Testset, number[], { stdout: string, stderr: string }]>;
})
.catch(({ stderr }: { stderr: string }) => exit(`${BOLD}${RED}Compile error:${CLEAR}\n\n${stderr}${SHOW_CURSOR}`))
.then(([testset, tests]: [Testset, number[]]) => {
process.stdout.write(HIDE_CURSOR);
let prm: Promise<Results> = Promise.resolve({
results: tests.map((index: number) => ({ index, state: State.WAITING } as IncompleteTestResult))
});
for (let i = 0; i < tests.length; i++) {
prm.then((results) => {
results.results[i].state = State.RUNNING;
update(results);
}).catch((error) => console.error(`${RED}Error: ${error}${CLEAR}`));
prm = prm.then((results) => {
return test(results, name, testset, tests[i], i);
});
}
return prm.then((results) => {
update(results);
process.stdout.write(DOWN(results.results.length));
exec(testset.scripts.cleanup);
return results;
});
})
.then((results: Results) => {
if ((commander as any).raw) {
process.stdout.write((results.results[0] as CompleteTestResult).output.actual);
process.stdout.write(SHOW_CURSOR);
} else if ((commander as any).inspect) {
new Inspector(results);
} else {
process.stdout.write(SHOW_CURSOR);
}
})
.catch((err: any) => exit(`${RED}Error:${err}${SHOW_CURSOR}${CLEAR}`));
}
function exec(cmd: string, input: string = "", timeout: number = 0): Promise<{ stdout: string, stderr: string, timeout?: boolean }> {
return new Promise((resolve, reject) => {
let child = cp.spawn("sh", ["-c", cmd]);
let stdout = "";
let stderr = "";
child.stdout.on("data", (data) => stdout += data);
child.stderr.on("data", (data) => stderr += data);
child.on("error", (err) => {
reject({ stdout: "", stderr: "" });
});
child.stdin.write(input);
child.stdin.end();
let timer: NodeJS.Timer;
if (timeout !== 0) {
timer = setTimeout(() => {
child.kill();
reject({ stdout: "", stderr: "", timeout: true });
}, timeout);
}
child.on("close", (code) => {
if (timer) {
clearTimeout(timer);
}
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject({ stdout, stderr });
}
});
});
}
function getAllTests(name: string) {
return fs.readdir(`.slither/${name}`)
.then((files) => {
let numbers = files.map((file) => parseInt(file.split(".")[0])).sort((a, b) => a - b);
let tests = [];
for (let i = 0; i < numbers.length; i++) {
if (tests[tests.length - 1] !== numbers[i]) {
tests.push(numbers[i]);
}
}
return tests;
});
}
function test(results: Results, name: string, testset: Testset, test: number, idx: number): Promise<Results> {
let inPrm = fs.readFile(`.slither/${name}/${test}.in`);
let outPrm = fs.readFile(`.slither/${name}/${test}.out`);
let filePrm = Promise.all([inPrm, outPrm]);
let startTime = Date.now();
filePrm.catch((err) =>
console.error(`${RED}Input or output file missing for test ${test}.${CLEAR}`));
return filePrm.then(([inBuf, outBuf]) => {
let input = inBuf.toString();
let output = outBuf.toString();
let testResult = results.results[idx];
let time = Date.now() - startTime;
let memory = rusage(rusage.RUSAGE_CHILDREN).maxrss;
return exec(testset.scripts.run, input, testset.limits.time)
.then(({ stdout, stderr, timeout }) => {
return checkers[testset.checker.type](testset.checker.options, { input, expected: output, actual: stdout })
.then(({ ok, display }) => {
Object.assign(testResult, {
time,
memory,
state: ok ? State.OK : State.WRONG_ANSWER,
output: {
expected: output,
actual: stdout,
displayExpected: display.expected,
displayActual: display.actual
}
});
})
.then(() => results);
})
.catch(({ stdout, stderr, timeout }) => {
if (timeout) {
Object.assign(testResult, {
time: testset.limits.time,
memory,
state: State.TIME_LIMIT_EXCEEDED,
output: { expected: output, actual: stdout }
});
} else if (/memory/i.test(stderr)) {
// cheating for now - any error containing "memory" is out-of-memory
Object.assign(testResult, {
time,
memory,
state: State.MEMORY_LIMIT_EXCEEDED,
output: { expected: output, actual: stdout }
});
} else {
Object.assign(testResult, {
time,
memory,
state: State.RUNTIME_ERROR,
output: { expected: output, actual: stdout, error: stderr }
});
}
return results;
});
}).catch((err) => {
console.error(`${RED}File system error: ${err}`);
return results;
});
}
function printTestResult(result: TestResult): void {
process.stdout.write(BOLD);
switch (result.state) {
case State.OK:
process.stdout.write(`${GREEN}${OK}`);
break;
case State.WRONG_ANSWER:
process.stdout.write(`${RED}${WRONG_ANSWER}`);
break;
case State.WAITING:
process.stdout.write(`${GRAY}${WAITING}`);
break;
case State.RUNNING:
process.stdout.write(`${CLEAR}${WAITING}`);
break;
case State.TIME_LIMIT_EXCEEDED:
process.stdout.write(`${YELLOW}${TIME_LIMIT_EXCEEDED}`);
break;
case State.RUNTIME_ERROR:
process.stdout.write(`${PURPLE}${RUNTIME_ERROR}`);
break;
case State.MEMORY_LIMIT_EXCEEDED:
process.stdout.write(`${CYAN}${MEMORY_LIMIT_EXCEEDED}`);
break;
}
process.stdout.write(" ");
process.stdout.write(CLEAR);
process.stdout.write(WHITE);
process.stdout.write(result.index.toString());
process.stdout.write(" ");
switch (result.state) {
case State.WAITING:
// do nothing
break;
case State.RUNNING:
process.stdout.write(GRAY);
process.stdout.write("[ Running ]");
break;
default:
process.stdout.write(GRAY);
process.stdout.write("[ ");
process.stdout.write(leftPad(result.time.toFixed(0), 4));
process.stdout.write(" ms / ");
process.stdout.write(leftPad((result.memory / 1048576).toFixed(3), 8));
process.stdout.write(" MB ]");
break;
}
}
function update(results: Results): void {
if ((commander as any).raw) {
return;
}
for (let result of results.results) {
printTestResult(result);
process.stdout.write("\n");
}
process.stdout.write(CLEAR);
process.stdout.write(UP(results.results.length));
}
function rightPad(str: string, length: number, padChar: string = " ") {
while (width(str) < length) {
str = str + padChar;
}
return str;
}
function leftPad(str: string, length: number, padChar: string = " ") {
while (width(str) < length) {
str = padChar + str;
}
return str;
}
class Inspector {
private results: Results;
private index: number;
public constructor(results: Results) {
this.results = results;
this.index = 0;
this.display();
process.stdin.on("keypress", (ch: string, key: { name: string, ctrl: boolean, meta: boolean, shift: boolean, sequence: string, code: string }) => {
if (key && key.ctrl && key.name === "c") {
process.stdout.write(SHOW_CURSOR);
process.stdin.pause();
}
switch (key.name) {
case "down":
this.index = (this.index + 1) % this.results.results.length;
this.display();
break;
case "up":
this.index = (this.index + this.results.results.length - 1) % this.results.results.length;
this.display();
break;
case "q":
process.stdout.write(SHOW_CURSOR);
process.stdin.pause();
}
});
(process.stdin as any).setRawMode(true);
}
private display() {
let current = this.results.results[this.index];
let cols = (process.stdout as any).columns;
process.stdout.write(ERASE_SCREEN);
process.stdout.write(RESET_CURSOR);
for (let i = 0; i < this.results.results.length; i++) {
if (this.index == i) {
process.stdout.write(BLUE);
process.stdout.write(">> ");
} else {
process.stdout.write(" ");
}
printTestResult(this.results.results[i]);
process.stdout.write(CLEAR);
process.stdout.write("\n");
}
process.stdout.write("\n");
process.stdout.write(rightPad(GRAY, cols, "-"));
process.stdout.write(`${CLEAR}\n\n${WHITE}${BOLD}Test ${current.index} • Verdict: ${CLEAR}`);
switch (current.state) {
case State.OK:
process.stdout.write(`${GREEN}OK${CLEAR}`);
break;
case State.WRONG_ANSWER:
process.stdout.write(`${RED}Wrong answer${CLEAR}`);
break;
case State.TIME_LIMIT_EXCEEDED:
process.stdout.write(`${YELLOW}Time limit exceeded${CLEAR}`);
break;
case State.RUNTIME_ERROR:
process.stdout.write(`${PURPLE}Runtime error${CLEAR}\n${BOLD}${WHITE}\n${RED}${current.output.error}${CLEAR}`);
break;
case State.MEMORY_LIMIT_EXCEEDED:
process.stdout.write(`${CYAN}Memory limit exceeded${CLEAR}`);
break;
}
process.stdout.write("\n\n");
let expected = "";
let actual = "";
let expectedCount = 0;
let actualCount = 0;
switch (current.state) {
case State.OK:
case State.WRONG_ANSWER:
expected = (current as CompleteTestResult).output.displayExpected;
actual = (current as CompleteTestResult).output.displayActual;
expectedCount = dropTrailingNewlines((current as CompleteTestResult).output.expected.split("\n")).length;
actualCount = dropTrailingNewlines((current as CompleteTestResult).output.actual.split("\n")).length;
break;
case State.MEMORY_LIMIT_EXCEEDED:
case State.RUNTIME_ERROR:
case State.TIME_LIMIT_EXCEEDED:
expected = numberLines(current.output.expected);
actual = numberLines(current.output.actual);
expectedCount = dropTrailingNewlines(current.output.expected.split("\n")).length;
actualCount = dropTrailingNewlines(current.output.actual.split("\n")).length;
break;
}
let size = Math.floor((cols - 3) / 2);
let expectedLines = expected.split("\n").slice(0, 30);
let actualLines = actual.split("\n").slice(0, 30);
expectedLines = formatDisplayLines(expectedLines, size);
actualLines = formatDisplayLines(actualLines, size);
let expectedHeader = rightPad(`${BOLD}${WHITE} Expected ${CLEAR}${GRAY}(${expectedCount} lines)${CLEAR}`, size);
let actualHeader = rightPad(`${BOLD}${WHITE} Actual ${CLEAR}${GRAY}(${actualCount} lines)${CLEAR}`, size);
process.stdout.write(`${expectedHeader} ${CLEAR}| ${actualHeader}\n`);
process.stdout.write(`${rightPad("", size, "-")}-+-${rightPad("", size, "-")}\n`);
for (let i = 0; i < Math.max(expectedLines.length, actualLines.length); i++) {
let expected = i < expectedLines.length ? expectedLines[i] : "";
let actual = i < actualLines.length ? actualLines[i] : "";
process.stdout.write(`${expected} | ${actual}\n`);
}
}
}
function dropTrailingNewlines(lines: string[]): string[] {
while (lines[lines.length - 1] === "") {
lines.pop();
}
return lines;
}
function numberLines(str: string): string {
let lines = dropTrailingNewlines(str.split(/\n/g));
let padWidth = lines.length.toString().length;
return lines
.map((l, i) => `${GRAY}${leftPad(`${i+1}`, padWidth)}${CLEAR} ${l}`)
.join("\n");
}
function formatDisplayLines(lines: string[], size: number): string[] {
while (lines[lines.length - 1] === "") {
lines.pop();
}
return lines
.map((l) => rightPad(truncate(l, size), size));
}
process.on('unhandledRejection', (reason: string, promise: string) => console.log(RED, "Unhandled promise rejection (please file a bug):", reason, promise));