quicktype/test/test.ts

663 строки
17 KiB
TypeScript
Executable File

import * as process from "process";
import * as os from "os";
import * as fs from "fs";
import * as path from "path";
import * as _ from "lodash";
import { main as quicktype_, Options } from "../cli/quicktype";
import { inParallel } from "./lib/multicore";
import deepEquals from "./lib/deepEquals";
import { randomBytes } from "crypto";
const Ajv = require("ajv");
const strictDeepEquals: (x: any, y: any) => boolean = require("deep-equal");
const shell = require("shelljs");
const Main = require("../output/Main");
const Samples = require("../output/Samples");
const exit = require("exit");
const chalk = require("chalk");
//////////////////////////////////////
// Constants
/////////////////////////////////////
const IS_CI = process.env.CI === "true";
const BRANCH = process.env.TRAVIS_BRANCH;
const IS_BLESSED = ["master"].indexOf(BRANCH) !== -1;
const IS_PUSH = process.env.TRAVIS_EVENT_TYPE === "push";
const IS_PR =
process.env.TRAVIS_PULL_REQUEST &&
process.env.TRAVIS_PULL_REQUEST !== "false";
const DEBUG = typeof process.env.DEBUG !== "undefined";
const CPUs = +process.env.CPUs || os.cpus().length;
function debug<T>(x: T): T {
if (DEBUG) {
console.log(x);
}
return x;
}
function samplesFromSources(
sources: string[],
prioritySamples: string[],
miscSamples: string[],
extension: string
): { priority: string[]; others: string[] } {
if (sources.length === 0) {
return { priority: prioritySamples, others: miscSamples };
} else if (sources.length === 1 && fs.lstatSync(sources[0]).isDirectory()) {
return { priority: testsInDir(sources[0], extension), others: [] };
} else {
return { priority: sources, others: [] };
}
}
//////////////////////////////////////
// Fixtures
/////////////////////////////////////
abstract class Fixture {
abstract name: string;
async setup(): Promise<void> {
return;
}
abstract getSamples(
sources: string[]
): { priority: string[]; others: string[] };
abstract runWithSample(
sample: string,
index: number,
total: number
): Promise<void>;
getRunDirectory(): string {
return `test/runs/${this.name}-${randomBytes(3).toString("hex")}`;
}
printRunMessage(
sample: string,
index: number,
total: number,
cwd: string,
shouldSkip: boolean
): void {
console.error(
`*`,
chalk.dim(`[${index + 1}/${total}]`),
chalk.magenta(this.name),
path.join(cwd, chalk.cyan(path.basename(sample))),
shouldSkip ? chalk.red("SKIP") : ""
);
}
}
abstract class JSONFixture extends Fixture {
protected abstract base: string;
protected setupCommand: string = null;
protected abstract diffViaSchema: boolean;
protected abstract output: string;
protected abstract topLevel: string;
protected skip: string[] = [];
protected abstract test(sample: string): Promise<void>;
async setup() {
if (!this.setupCommand) {
return;
}
console.error(`* Setting up`, chalk.magenta(this.name), `fixture`);
await inDir(this.base, async () => {
exec(this.setupCommand);
});
}
private shouldSkipTest(sample: string): boolean {
if (fs.statSync(sample).size > 32 * 1024 * 1024) {
return true;
}
let skips = this.skip;
return _.includes(skips, path.basename(sample));
}
getSamples(sources: string[]): { priority: string[]; others: string[] } {
// FIXME: this should only run once
const prioritySamples = _.concat(
testsInDir("test/inputs/json/priority", "json"),
testsInDir("test/inputs/json/samples", "json")
);
const miscSamples = testsInDir("test/inputs/json/misc", "json");
let { priority, others } = samplesFromSources(
sources,
prioritySamples,
miscSamples,
"json"
);
if (IS_CI && !IS_PR && !IS_BLESSED) {
// Run only priority sources on low-priority CI branches
priority = prioritySamples;
others = [];
} else if (IS_CI) {
// On CI, we run a maximum number of test samples. First we test
// the priority samples to fail faster, then we continue testing
// until testMax with random sources.
const testMax = 100;
priority = prioritySamples;
others = _.chain(miscSamples)
.shuffle()
.take(testMax - prioritySamples.length)
.value();
}
return { priority, others };
}
async runWithSample(sample: string, index: number, total: number) {
const cwd = this.getRunDirectory();
let sampleFile = path.basename(sample);
let shouldSkip = this.shouldSkipTest(sample);
this.printRunMessage(sample, index, total, cwd, shouldSkip);
if (shouldSkip) {
return;
}
shell.cp("-R", this.base, cwd);
shell.cp(sample, cwd);
await inDir(cwd, async () => {
// Generate code from the sample
await quicktype({
src: [sampleFile],
out: this.output,
topLevel: this.topLevel
});
try {
await this.test(sampleFile);
} catch (e) {
failWith("Fixture threw an exception", { error: e });
}
if (this.diffViaSchema) {
debug("* Diffing with code generated via JSON Schema");
// Make a schema
await quicktype({
src: [sampleFile],
out: "schema.json",
topLevel: this.topLevel
});
// Quicktype from the schema and compare to expected code
shell.mv(this.output, `${this.output}.expected`);
await quicktype({
src: ["schema.json"],
srcLang: "schema",
out: this.output,
topLevel: this.topLevel
});
// Compare fixture.output to fixture.output.expected
exec(
`diff -Naur ${this.output}.expected ${this.output} > /dev/null 2>&1`
);
}
});
shell.rm("-rf", cwd);
}
}
//////////////////////////////////////
// C# tests
/////////////////////////////////////
class CSharpJSONFixture extends JSONFixture {
name = "csharp";
base = "test/fixtures/csharp";
// https://github.com/dotnet/cli/issues/1582
setupCommand = "dotnet restore --no-cache";
diffViaSchema = true;
output = "QuickType.cs";
topLevel = "QuickType";
async test(sample: string) {
compareJsonFileToJson({
expectedFile: sample,
jsonCommand: `dotnet run "${sample}"`,
strict: false
});
}
}
//////////////////////////////////////
// Java tests
/////////////////////////////////////
class JavaJSONFixture extends JSONFixture {
name = "java";
base = "test/fixtures/java";
diffViaSchema = false;
output = "src/main/java/io/quicktype/TopLevel.java";
topLevel = "TopLevel";
skip = ["identifiers.json", "simple-identifiers.json", "blns-object.json"];
async test(sample: string) {
exec(`mvn package`);
compareJsonFileToJson({
expectedFile: sample,
jsonCommand: `java -cp target/QuickTypeTest-1.0-SNAPSHOT.jar io.quicktype.App "${sample}"`,
strict: false
});
}
}
//////////////////////////////////////
// Go tests
/////////////////////////////////////
class GoJSONFixture extends JSONFixture {
name = "golang";
base = "test/fixtures/golang";
diffViaSchema = true;
output = "quicktype.go";
topLevel = "TopLevel";
skip = ["identifiers.json", "simple-identifiers.json", "blns-object.json"];
async test(sample: string) {
compareJsonFileToJson({
expectedFile: sample,
jsonCommand: `go run main.go quicktype.go < "${sample}"`,
strict: false
});
}
}
//////////////////////////////////////
// JSON Schema tests
/////////////////////////////////////
class JSONSchemaJSONFixture extends JSONFixture {
name = "schema-json";
base = "test/fixtures/golang";
diffViaSchema = false;
output = "schema.json";
topLevel = "schema";
skip = ["identifiers.json", "simple-identifiers.json", "blns-object.json"];
async test(sample: string) {
let input = JSON.parse(fs.readFileSync(sample, "utf8"));
let schema = JSON.parse(fs.readFileSync("schema.json", "utf8"));
let ajv = new Ajv();
let valid = ajv.validate(schema, input);
if (!valid) {
failWith("Generated schema does not validate input JSON.", {
sample
});
}
// Generate Go from the schema
await quicktype({
src: ["schema.json"],
srcLang: "schema",
out: "quicktype.go",
topLevel: "TopLevel"
});
// Parse the sample with Go generated from its schema, and compare to the sample
compareJsonFileToJson({
expectedFile: sample,
jsonCommand: `go run main.go quicktype.go < "${sample}"`,
strict: false
});
// Generate a schema from the schema, making sure the schemas are the same
let schemaSchema = "schema-from-schema.json";
await quicktype({
src: ["schema.json"],
srcLang: "schema",
lang: "schema",
out: schemaSchema
});
compareJsonFileToJson({
expectedFile: "schema.json",
jsonFile: schemaSchema,
strict: true
});
}
}
//////////////////////////////////////
// Elm tests
/////////////////////////////////////
class ElmJSONFixture extends JSONFixture {
name = "elm";
base = "test/fixtures/elm";
setupCommand = "rm -rf elm-stuff/build-artifacts && elm-make --yes";
diffViaSchema = true;
output = "QuickType.elm";
topLevel = "QuickType";
skip = ["identifiers.json", "simple-identifiers.json", "blns-object.json"];
async test(sample: string) {
exec(`elm-make Main.elm QuickType.elm --output elm.js`);
compareJsonFileToJson({
expectedFile: sample,
jsonCommand: `node ./runner.js "${sample}"`,
strict: false
});
}
}
//////////////////////////////////////
// Swift tests
/////////////////////////////////////
class SwiftJSONFixture extends JSONFixture {
name = "swift";
base = "test/fixtures/swift";
diffViaSchema = false;
output = "quicktype.swift";
topLevel = "TopLevel";
skip = ["identifiers.json", "no-classes.json", "blns-object.json"];
async test(sample: string) {
exec(`swiftc -o quicktype main.swift quicktype.swift`);
compareJsonFileToJson({
expectedFile: sample,
jsonCommand: `./quicktype "${sample}"`,
strict: false
});
}
}
//////////////////////////////////////
// TypeScript test
/////////////////////////////////////
class TypeScriptJSONFixture extends JSONFixture {
name = "typescript";
base = "test/fixtures/typescript";
diffViaSchema = true;
output = "TopLevel.ts";
topLevel = "TopLevel";
skip = ["identifiers.json"];
async test(sample: string) {
compareJsonFileToJson({
expectedFile: sample,
// We have to unset TS_NODE_PROJECT because it gets set on the workers
// to the root test/tsconfig.json
jsonCommand: `TS_NODE_PROJECT= ts-node main.ts \"${sample}\"`,
strict: false
});
}
}
//////////////////////////////////////
// JSON Schema fixture
/////////////////////////////////////
class JSONSchemaFixture extends Fixture {
name = "schema";
getSamples(sources: string[]) {
const prioritySamples = testsInDir("test/inputs/schema/", "schema");
return samplesFromSources(sources, prioritySamples, [], "schema");
}
async runWithSample(sample: string, index: number, total: number) {
const cwd = this.getRunDirectory();
let sampleFile = path.basename(sample);
this.printRunMessage(sample, index, total, cwd, false);
const base = path.join(
path.dirname(sample),
path.basename(sample, ".schema")
);
const jsonFiles = [];
let fn = `${base}.json`;
if (fs.existsSync(fn)) {
jsonFiles.push(fn);
}
let i = 1;
for (;;) {
fn = `${base}.${i.toString()}.json`;
if (fs.existsSync(fn)) {
jsonFiles.push(fn);
} else {
break;
}
i++;
}
if (jsonFiles.length === 0) {
failWith("No JSON input files", { base });
}
shell.cp("-R", "test/fixtures/golang", cwd);
shell.cp.apply(null, _.concat(sample, jsonFiles, cwd));
await inDir(cwd, async () => {
await quicktype({
srcLang: "schema",
src: [sampleFile],
topLevel: "TopLevel",
out: "quicktype.go"
});
for (const json of jsonFiles) {
const jsonBase = path.basename(json);
compareJsonFileToJson({
expectedFile: jsonBase,
jsonCommand: `go run main.go quicktype.go < "${jsonBase}"`,
strict: false
});
}
shell.rm("-rf", cwd);
});
}
}
const FIXTURES: Fixture[] = [
new CSharpJSONFixture(),
new JavaJSONFixture(),
new GoJSONFixture(),
new JSONSchemaJSONFixture(),
new ElmJSONFixture(),
new SwiftJSONFixture(),
new TypeScriptJSONFixture(),
new JSONSchemaFixture()
].filter(
({ name }) => !process.env.FIXTURE || process.env.FIXTURE.includes(name)
);
//////////////////////////////////////
// Test driver
/////////////////////////////////////
function failWith(message: string, obj: any) {
obj.cwd = process.cwd();
console.error(chalk.red(message));
console.error(chalk.red(JSON.stringify(obj, null, " ")));
throw obj;
}
async function time<T>(work: () => Promise<T>): Promise<[T, number]> {
let start = +new Date();
let result = await work();
let end = +new Date();
return [result, end - start];
}
async function quicktype(opts: Options) {
let [result, duration] = await time(async () => {
await quicktype_(opts);
});
}
function exec(
s: string,
opts: { silent: boolean } = { silent: !DEBUG },
cb?: any
): { stdout: string; code: number } {
debug(s);
let result = shell.exec(s, opts, cb);
if (result.code !== 0) {
console.error(result.stdout);
console.error(result.stderr);
failWith("Command failed", {
command: s,
code: result.code
});
}
return result;
}
function callAndReportFailure<T>(message: string, f: () => T): T {
try {
return f();
} catch (e) {
failWith(message, { error: e });
}
}
type ComparisonArgs = {
expectedFile: string;
jsonFile?: string;
jsonCommand?: string;
strict: boolean;
};
function compareJsonFileToJson(args: ComparisonArgs) {
debug(args);
let { expectedFile, jsonFile, jsonCommand, strict } = args;
const jsonString = jsonFile
? callAndReportFailure("Could not read JSON output file", () =>
fs.readFileSync(jsonFile, "utf8")
)
: callAndReportFailure(
"Could not run command for JSON output",
() => exec(jsonCommand).stdout
);
const givenJSON = callAndReportFailure("Could not parse output JSON", () =>
JSON.parse(jsonString)
);
const expectedJSON = callAndReportFailure(
"Could not read or parse expected JSON file",
() => JSON.parse(fs.readFileSync(expectedFile, "utf8"))
);
let jsonAreEqual = strict
? callAndReportFailure("Failed to strictly compare objects", () =>
strictDeepEquals(givenJSON, expectedJSON)
)
: callAndReportFailure("Failed to compare objects.", () =>
deepEquals(expectedJSON, givenJSON)
);
if (!jsonAreEqual) {
failWith("Error: Output is not equivalent to input.", {
expectedFile,
jsonCommand,
jsonFile
});
}
}
async function inDir(dir: string, work: () => Promise<void>) {
let origin = process.cwd();
debug(`cd ${dir}`);
process.chdir(dir);
await work();
process.chdir(origin);
}
type WorkItem = { sample: string; fixtureName: string };
async function main(sources: string[]) {
// Get an array of all { sample, fixtureName } objects we'll run
const samples = _.map(FIXTURES, fixture => ({
fixtureName: fixture.name,
samples: fixture.getSamples(sources)
}));
const priority = _.flatMap(samples, x =>
_.map(x.samples.priority, s => ({ fixtureName: x.fixtureName, sample: s }))
);
const others = _.flatMap(samples, x =>
_.map(x.samples.others, s => ({ fixtureName: x.fixtureName, sample: s }))
);
const tests = _.concat(_.shuffle(priority), _.shuffle(others));
await inParallel({
queue: tests,
workers: CPUs,
setup: async () => {
testCLI();
console.error(
`* Running ${tests.length} tests between ${FIXTURES.length} fixtures`
);
for (const fixture of FIXTURES) {
exec(`rm -rf test/runs`);
exec(`mkdir -p test/runs`);
await fixture.setup();
}
},
map: async ({ sample, fixtureName }: WorkItem, index) => {
let fixture = _.find(FIXTURES, { name: fixtureName });
try {
await fixture.runWithSample(sample, index, tests.length);
} catch (e) {
console.trace(e);
exit(1);
}
}
});
}
function testCLI() {
console.log(`* CLI sanity check`);
const qt = "node output/quicktype.js";
exec(`${qt} --help`);
}
function testsInDir(dir: string, extension: string): string[] {
return shell.ls(`${dir}/*.${extension}`);
}
// skip 2 `node` args
main(process.argv.slice(2)).catch(reason => {
console.error(reason);
process.exit(1);
});