UNPKG

9.1 kBPlain TextView Raw
1import assert from "assert";
2import cp from "child_process";
3import { CloseFn as FgCloseFn, proxy as fgChildProxy } from "demurgos-foreground-child";
4import findUp from "find-up";
5import fs from "fs";
6import { fromSysPath, toSysPath } from "furi";
7import sysPath from "path";
8import Exclude from "test-exclude";
9import urlMod from "url";
10import vinylFs from "vinyl-fs";
11import yargs from "yargs";
12import { asyncDonePromise } from "./async-done-promise";
13import { CoverageFilter, fromGlob } from "./filter";
14import { GetText, getText as defaultGetText, GetTextSync, getTextSyncFromSourceStore } from "./get-text";
15import { createReporter, reportStream, reportVinyl } from "./report";
16import { Reporter, StreamReporter, VinylReporter } from "./reporter";
17import { DEFAULT_REGISTRY } from "./reporter-registry";
18import { RichProcessCov, spawnInspected } from "./spawn-inspected";
19import { processCovsToIstanbul } from "./to-istanbul";
20import { VERSION } from "./version";
21
22const DEFAULT_GLOBS: ReadonlyArray<string> = Exclude.defaultExclude.map((pattern: string) => `!${pattern}`);
23
24interface Watermarks {
25 lines: [number, number];
26 functions: [number, number];
27 branches: [number, number];
28 statements: [number, number];
29}
30
31export interface FileConfig {
32 reporters?: ReadonlyArray<string>;
33 globs?: ReadonlyArray<string>;
34 coverageDir?: string;
35 waterMarks?: Watermarks;
36}
37
38export interface CliConfig {
39 reporters?: ReadonlyArray<string>;
40 globs?: ReadonlyArray<string>;
41 coverageDir?: string;
42 command: ReadonlyArray<string>;
43}
44
45export interface ResolvedConfig {
46 reporters: ReadonlyArray<string>;
47 globs: ReadonlyArray<string>;
48 coverageDir: string;
49 waterMarks: Watermarks;
50 command: ReadonlyArray<string>;
51}
52
53export interface MessageAction {
54 action: "message";
55 message: string;
56 error?: Error;
57}
58
59export interface RunAction {
60 action: "run";
61 config: ResolvedConfig;
62}
63
64export type CliAction = MessageAction | RunAction;
65
66export type ParseArgsResult = MessageAction | {action: "run"; config: CliConfig};
67
68const DEFAULT_WATERMARKS: Watermarks = Object.freeze({
69 lines: [80, 95] as [number, number],
70 functions: [80, 95] as [number, number],
71 branches: [80, 95] as [number, number],
72 statements: [80, 95] as [number, number],
73});
74
75// TODO: Fix yargs type definition
76const ARG_PARSER: yargs.Argv = yargs() as any;
77
78ARG_PARSER
79 .scriptName("c88")
80 .version(VERSION)
81 .usage("$0 [opts] [script] [opts]")
82 .locale("en")
83 .option("reporter", {
84 alias: "r",
85 describe: "coverage reporter(s) to use",
86 default: "text",
87 })
88 .option("match", {
89 alias: "m",
90 default: DEFAULT_GLOBS,
91 // tslint:disable-next-line:max-line-length
92 describe: "a list of specific files and directories that should be matched, glob patterns are supported.",
93 })
94 .option("coverage-directory", {
95 default: "coverage",
96 describe: "directory to output coverage JSON and reports",
97 })
98 .pkgConf("c88")
99 .demandCommand(1)
100 .epilog("visit https://git.io/vHysA for list of available reporters");
101
102// tslint:disable:whitespace
103
104/**
105 * Executes the c88 CLI
106 *
107 * @param args CLI arguments
108 * @param cwd Current working directory
109 * @param proc Current process
110 */
111export async function execCli(args: string[], cwd: string, proc: NodeJS.Process): Promise<number> {
112 const action: CliAction = await getAction(args, cwd);
113
114 switch (action.action) {
115 case "message":
116 process.stderr.write(Buffer.from(action.message));
117 return action.error === undefined ? 0 : 1;
118 case "run":
119 return execRunAction(action, cwd, proc);
120 default:
121 throw new Error(`AssertionError: Unexpected \`action\`: ${(action as any).action}`);
122 }
123}
124
125function resolveConfig(fileConfig: FileConfig, cliConfig: CliConfig): ResolvedConfig {
126 return {
127 command: cliConfig.command,
128 reporters: cliConfig.reporters !== undefined ? cliConfig.reporters : ["text"],
129 globs: cliConfig.globs !== undefined ? cliConfig.globs : DEFAULT_GLOBS,
130 waterMarks: fileConfig.waterMarks !== undefined ? fileConfig.waterMarks : DEFAULT_WATERMARKS,
131 coverageDir: cliConfig.coverageDir !== undefined ? cliConfig.coverageDir : "coverage",
132 };
133}
134
135async function execRunAction({config}: RunAction, cwd: string, proc: NodeJS.Process): Promise<number> {
136 const file: string = config.command[0];
137 const args: string[] = config.command.slice(1);
138 const filter: CoverageFilter = fromGlob({patterns: config.globs, base: fromSysPath(cwd)});
139
140 const subProcessExit: DeferredPromise<number> = deferPromise();
141
142 async function onRootProcess(inspectedProc: cp.ChildProcess): Promise<void> {
143 const closeFn: FgCloseFn = await fgChildProxy(proc, inspectedProc);
144 if (closeFn.signal !== null) {
145 subProcessExit.reject(new Error(`Process killed by signal: ${closeFn.signal}`));
146 } else {
147 subProcessExit.resolve(closeFn.code!);
148 }
149 }
150
151 let processCovs: RichProcessCov[];
152 try {
153 processCovs = await spawnInspected(file, args, {filter, onRootProcess});
154 } catch (err) {
155 proc.stderr.write(Buffer.from(`${err.toString()}\n`));
156 return 1;
157 }
158 const exitCode: number = await subProcessExit.promise;
159
160 try {
161 const reporter: Reporter = createReporter(DEFAULT_REGISTRY, config.reporters, {waterMarks: config.waterMarks});
162 const resolvedCoverageDir: string = sysPath.join(cwd, config.coverageDir);
163 const coverageDir: urlMod.URL = fromSysPath(resolvedCoverageDir);
164 await report(reporter, processCovs, proc.stdout, coverageDir);
165 return exitCode;
166 } catch (err) {
167 proc.stderr.write(Buffer.from(err.toString() + "\n"));
168 return Math.max(1, exitCode);
169 }
170}
171
172export async function report(
173 reporter: Reporter,
174 processCovs: ReadonlyArray<RichProcessCov>,
175 outStream: NodeJS.WritableStream,
176 outDir: Readonly<urlMod.URL>,
177 getText: GetText = defaultGetText,
178): Promise<void> {
179 const {coverageMap, sources} = await processCovsToIstanbul(processCovs, getText);
180 const getSourcesSync: GetTextSync = getTextSyncFromSourceStore(sources);
181
182 const tasks: Promise<void>[] = [];
183 if (reporter.reportStream !== undefined) {
184 const stream: NodeJS.ReadableStream = reportStream(reporter as StreamReporter, coverageMap, getSourcesSync);
185 tasks.push(pipeData(stream, outStream));
186 }
187 if (reporter.reportVinyl !== undefined) {
188 const stream: NodeJS.ReadableStream = reportVinyl(reporter as VinylReporter, coverageMap, getSourcesSync)
189 .pipe(vinylFs.dest(toSysPath(outDir.href)));
190 tasks.push(asyncDonePromise(() => stream));
191 }
192
193 await Promise.all(tasks);
194}
195
196export async function getAction(args: string[], cwd: string): Promise<CliAction> {
197 const parsed: ParseArgsResult = parseArgs(args);
198 if (parsed.action !== "run") {
199 return parsed;
200 }
201 const fileConfig: FileConfig = await readConfigFile(cwd);
202 return {
203 action: "run",
204 config: resolveConfig(fileConfig, parsed.config),
205 };
206}
207
208export function parseArgs(args: string[]): ParseArgsResult {
209 // The yargs pure API is kinda strange to use (apart from requiring a callback):
210 // The error can either be defined, `undefined` or `null`.
211 // If it is defined or `null`, then `output` should be a non-empty string
212 // intended to be written to stderr. `parsed` is defined but it should be
213 // ignored in this case.
214 // If `err` is `undefined`, then `output` is an empty string and `parsed`
215 // contains the succesfully parsed args.
216 // tslint:disable:variable-name
217 let _err: Error | undefined | null;
218 let _parsed: any;
219 let _output: string;
220 let isParsed: boolean = false;
221 ARG_PARSER.parse(args, (err: Error | undefined | null, parsed: any, output: string): void => {
222 _err = err;
223 _parsed = parsed;
224 _output = output;
225 isParsed = true;
226 });
227 assert(isParsed);
228 const err: Error | undefined | null = _err!;
229 const parsed: any = _parsed!;
230 const output: string = _output!;
231 if (err === null) {
232 // Successfully parsed
233 return {
234 action: "run",
235 config: {
236 command: parsed._,
237 reporters: Array.isArray(parsed.reporter) ? parsed.reporter : [parsed.reporter],
238 globs: parsed.match,
239 },
240 };
241 } else {
242 return {action: "message", message: output, error: err};
243 }
244}
245
246async function readConfigFile(cwd: string): Promise<FileConfig> {
247 const configPath: string | null = findUp.sync([".c88rc", ".c88rc.json"]);
248 if (configPath === null) {
249 return Object.create(null);
250 }
251 return JSON.parse(fs.readFileSync(configPath, "UTF-8"));
252}
253
254interface DeferredPromise<T> {
255 promise: Promise<T>;
256
257 resolve(value: T): void;
258
259 reject(reason: any): void;
260}
261
262function deferPromise<T>(): DeferredPromise<T> {
263 let resolve: (value: T) => void;
264 let reject: (reason: any) => void;
265 const promise: Promise<T> = new Promise<T>((res, rej) => {
266 resolve = res;
267 reject = rej;
268 });
269 return {resolve: resolve!, reject: reject!, promise};
270}
271
272function pipeData(src: NodeJS.ReadableStream, dest: NodeJS.WritableStream): Promise<void> {
273 return new Promise<void>((resolve, reject) => {
274 src.on("data", chunk => dest.write(chunk));
275 src.on("error", reject);
276 src.on("end", resolve);
277 });
278}
279
\No newline at end of file