Adding a new concept of reporters (#54)

* adding basics e2e

* Change files

* mock it

* executable

* a;ekfaj;elkfja;ldjfk

* fixing up install of lage

* fixing the symlink of lage

* WIP: major refactor to allow for reporters

* getting json reporter working!

* clean up index

* adding big app tests

* get rid of change file

* fix CliOptions

* Change files

* adding docs about the CliOptions changes

* get rid unused types
This commit is contained in:
Kenneth Chau 2020-08-05 17:02:45 -07:00 коммит произвёл GitHub
Родитель e590f2de84
Коммит 2599523e22
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
32 изменённых файлов: 822 добавлений и 480 удалений

Просмотреть файл

@ -0,0 +1,8 @@
{
"type": "minor",
"comment": "Changing the logger to allow json mode and adding real e2e tests for orders",
"packageName": "lage",
"email": "kchau@microsoft.com",
"dependentChangeType": "patch",
"date": "2020-08-05T23:54:28.996Z"
}

Просмотреть файл

@ -73,6 +73,14 @@ This has the semantic of running tasks up to what is specified in the command li
such as with `--scope` or `--since`
#### grouped
_type: boolean_
Specify whether to make the console logger to group the logs per package task
Example: `lage --grouped`
#### ignore
_type: string[]_
@ -115,6 +123,14 @@ Creates a flamegraph-profile JSON for Chromium-based devtool
Pay attention to the output summary to find the location of the JSON file.
#### reporter
_type: string_
Specify whether to use the JSON Reporter to create a parsable log output
Example: `lage --reporter json`
#### resetCache
_type: boolean_

Просмотреть файл

@ -3,13 +3,14 @@ import { logger } from "../logger";
import { Config } from "../types/Config";
import { generateTopologicGraph } from "../workspace/generateTopologicalGraph";
import { signal } from "../task/abortSignal";
import { killAllActiveProcesses } from "../task/npmTask";
import { displayReportAndExit } from "../displayReportAndExit";
import { createContext } from "../context";
import { runTasks } from "../task/taskRunner";
import { NpmScriptTask } from "../task/NpmScriptTask";
import { Reporter } from "../logger/reporters/Reporter";
// Create context
export async function run(cwd: string, config: Config) {
export async function run(cwd: string, config: Config, reporters: Reporter[]) {
const context = createContext(config);
const workspace = getWorkspace(cwd, config);
@ -21,20 +22,20 @@ export async function run(cwd: string, config: Config) {
// die faster if an abort signal is seen
signal.addEventListener("abort", () => {
killAllActiveProcesses();
displayReportAndExit(context);
NpmScriptTask.killAllActiveProcesses();
displayReportAndExit(reporters, context);
});
try {
await runTasks({ graph, workspace, context, config });
} catch (e) {
logger.error("runTasks", e);
logger.error("runTasks: " + e);
}
if (config.profile) {
const profileFile = profiler.output();
logger.info("runTasks", `Profile saved to ${profileFile}`);
logger.info(`runTasks: Profile saved to ${profileFile}`);
}
displayReportAndExit(context);
displayReportAndExit(reporters, context);
}

Просмотреть файл

@ -38,6 +38,8 @@ export function getConfig(cwd: string): Config {
: true;
return {
reporter: parsedArgs.reporter || "npmLog",
grouped: parsedArgs.grouped || false,
args: getPassThroughArgs(parsedArgs),
cache: parsedArgs.cache === false ? false : true,
resetCache: parsedArgs.resetCache || false,

Просмотреть файл

@ -15,13 +15,13 @@ export function createContext(config: Pick<Config, "concurrency">): RunContext {
measures: {
start: [0, 0],
duration: [0, 0],
taskStats: [],
failedTask: undefined,
},
tasks: new Map(),
profiler: new Profiler({
concurrency,
prefix: "lage",
outDir: profilerOutputDir,
})
}),
};
}

Просмотреть файл

@ -1,9 +1,16 @@
import { RunContext } from "./types/RunContext";
import { reportSummary } from "./logger/reportSummary";
import { Reporter } from "./logger/reporters/Reporter";
export function displayReportAndExit(context: RunContext) {
export function displayReportAndExit(
reporters: Reporter[],
context: RunContext
) {
context.measures.duration = process.hrtime(context.measures.start);
reportSummary(context);
for (const reporter of reporters) {
reporter.summarize(context);
}
if (context.measures.failedTask) {
process.exit(1);
}

Просмотреть файл

@ -1,11 +1,12 @@
import { getConfig } from "./config/getConfig";
import { logLevel } from "./logger";
import { init } from "./command/init";
import { run } from "./command/run";
import { showHelp } from "./showHelp";
console.log(`🔨 Lage task runner - let's make it`);
console.log(``);
import { logger } from "./logger";
import { Logger } from "./logger/Logger";
import { NpmLogReporter } from "./logger/reporters/NpmLogReporter";
import { LogLevel } from "./logger/LogLevel";
import { JsonReporter } from "./logger/reporters/JsonReporter";
// Parse CLI args
const cwd = process.cwd();
@ -13,14 +14,25 @@ try {
const config = getConfig(cwd);
// Initialize logger
if (config.verbose) {
logLevel("verbose");
}
const logLevel = config.verbose ? LogLevel.verbose : LogLevel.info;
const reporters = [
config.reporter === "json"
? new JsonReporter({ logLevel })
: new NpmLogReporter({
logLevel,
grouped: config.grouped,
}),
];
Logger.reporters = reporters;
logger.info(`Lage task runner - let's make it`);
if (config.command[0] === "init") {
init(cwd);
} else {
run(cwd, config);
run(cwd, config, reporters);
}
} catch (e) {
console.error(e);

15
src/logger/LogEntry.ts Normal file
Просмотреть файл

@ -0,0 +1,15 @@
import { LogLevel } from "./LogLevel";
export interface LogEntry {
timestamp: number;
level: LogLevel;
msg: string;
data?: TaskData;
}
export interface TaskData {
status?: "pending" | "started" | "completed" | "failed" | "skipped";
package?: string;
task?: string;
duration?: string;
}

7
src/logger/LogLevel.ts Normal file
Просмотреть файл

@ -0,0 +1,7 @@
export enum LogLevel {
error = 10,
warn = 20,
info = 30,
verbose = 40,
silly = 50,
}

47
src/logger/Logger.ts Normal file
Просмотреть файл

@ -0,0 +1,47 @@
import { LogEntry, TaskData } from "./LogEntry";
import { LogLevel } from "./LogLevel";
import { Reporter } from "./reporters/Reporter";
import { NpmLogReporter } from "./reporters/NpmLogReporter";
export class Logger {
static reporters: Reporter[] = [
new NpmLogReporter({ logLevel: LogLevel.info }),
];
logs: LogEntry[] = [];
log(level: LogLevel, msg: string, data?: TaskData) {
const entry = {
timestamp: Date.now(),
level,
msg,
data,
};
this.logs.push(entry);
for (const reporter of Logger.reporters) {
reporter.log(entry);
}
}
info(msg: string, data?: TaskData) {
this.log(LogLevel.info, msg, data);
}
warn(msg: string, data?: TaskData) {
this.log(LogLevel.warn, msg, data);
}
error(msg: string, data?: TaskData) {
this.log(LogLevel.error, msg, data);
}
verbose(msg: string, data?: TaskData) {
this.log(LogLevel.verbose, msg, data);
}
silly(msg: string, data?: TaskData) {
this.log(LogLevel.silly, msg, data);
}
}

Просмотреть файл

@ -0,0 +1,35 @@
import { Writable } from "stream";
import { TaskLogger } from "./TaskLogger";
export class TaskLogWritable extends Writable {
private buffer: string = "";
constructor(private taskLogger: TaskLogger) {
super();
}
_write(
chunk: Buffer,
_encoding: string,
callback: (error?: Error | null) => void
) {
let prev = 0;
let curr = 0;
while (curr < chunk.byteLength) {
if (chunk[curr] === 13 || (chunk[curr] === 10 && curr - prev > 1)) {
this.buffer =
this.buffer +
chunk
.slice(prev, curr)
.toString()
.replace(/^(\r\n|\n|\r)|(\r\n|\n|\r)$/g, "")
.trimRight();
this.taskLogger.verbose(this.buffer);
this.buffer = "";
prev = curr;
}
curr++;
}
callback();
}
}

34
src/logger/TaskLogger.ts Normal file
Просмотреть файл

@ -0,0 +1,34 @@
import { Logger } from "./Logger";
import { TaskData } from "./LogEntry";
export class TaskLogger {
logger: Logger;
constructor(private pkg: string, private task: string) {
this.logger = new Logger();
}
info(msg: string, data?: TaskData) {
this.logger.info(msg, { package: this.pkg, task: this.task, ...data });
}
warn(msg: string, data?: TaskData) {
this.logger.warn(msg, { package: this.pkg, task: this.task, ...data });
}
error(msg: string, data?: TaskData) {
this.logger.error(msg, { package: this.pkg, task: this.task, ...data });
}
verbose(msg: string, data?: TaskData) {
this.logger.verbose(msg, { package: this.pkg, task: this.task, ...data });
}
silly(msg: string, data?: TaskData) {
this.logger.silly(msg, { package: this.pkg, task: this.task, ...data });
}
getLogs() {
return this.logger.logs;
}
}

Просмотреть файл

@ -1,152 +1,3 @@
import log from "npmlog";
import { getTaskId } from "../task/taskId";
import { Writable } from "stream";
import chalk from "chalk";
import { TaskLogs, TaskLogger } from "../types/Task";
import { Logger } from "./Logger";
const taskLogs: TaskLogs = new Map();
const maxLengths = {
pkg: 0,
task: 0,
};
const colors = {
info: chalk.white,
verbose: chalk.gray,
warn: chalk.white,
error: chalk.white,
task: chalk.cyan,
pkg: chalk.magenta,
};
export function getTaskLogs() {
return taskLogs;
}
export function setTaskLogMaxLengths(
maxPkgLength: number,
maxTaskLength: number
) {
maxLengths.pkg = maxPkgLength;
maxLengths.task = maxTaskLength;
}
export function getTaskLogPrefix(pkg: string, task: string) {
return `${colors.pkg(pkg.padStart(maxLengths.pkg))} ${colors.task(
task.padStart(maxLengths.task)
)}`;
}
function addToTaskLog(pkg: string, task: string, message: string) {
const taskId = getTaskId(pkg, task);
if (!taskLogs.has(taskId)) {
taskLogs.set(taskId, []);
}
taskLogs.get(taskId)!.push(message);
}
function normalize(prefixOrMessage: string, message?: string) {
if (typeof message === "string") {
const prefix = prefixOrMessage;
return { prefix, message };
} else {
const prefix = "";
const message = prefixOrMessage;
return { prefix, message };
}
}
function info(prefixOrMessage: string, message?: string) {
const normalizedArgs = normalize(prefixOrMessage, message);
return log.info(normalizedArgs.prefix, colors.info(normalizedArgs.message));
}
function warn(prefixOrMessage: string, message?: string) {
const normalizedArgs = normalize(prefixOrMessage, message);
return log.warn(normalizedArgs.prefix, colors.warn(normalizedArgs.message));
}
function error(prefixOrMessage: string, message?: string) {
const normalizedArgs = normalize(prefixOrMessage, message);
return log.error(normalizedArgs.prefix, colors.error(normalizedArgs.message));
}
function verbose(prefixOrMessage: string, message?: string, ...args: any) {
const normalizedArgs = normalize(prefixOrMessage, message);
return log.verbose(
normalizedArgs.prefix,
colors.verbose(normalizedArgs.message)
);
}
export class NpmLogWritable extends Writable {
private buffer: string = "";
private taskLogger: TaskLogger;
constructor(pkg: string, task: string) {
super();
this.taskLogger = taskLogger(pkg, task);
}
_write(
chunk: Buffer,
_encoding: string,
callback: (error?: Error | null) => void
) {
let prev = 0;
let curr = 0;
while (curr < chunk.byteLength) {
if (chunk[curr] === 13 || (chunk[curr] === 10 && curr - prev > 1)) {
this.buffer =
this.buffer +
chunk
.slice(prev, curr)
.toString()
.replace(/^(\r\n|\n|\r)|(\r\n|\n|\r)$/g, "")
.trimRight();
this.taskLogger.verbose(this.buffer);
this.buffer = "";
prev = curr;
}
curr++;
}
callback();
}
}
export function logLevel(level: "error" | "warn" | "info" | "verbose") {
log.level = level;
}
export const logger = {
info,
warn,
error,
verbose,
};
export function taskLogger(pkg, task) {
return {
info: (message: string) => {
addToTaskLog(pkg, task, message);
return log.info(getTaskLogPrefix(pkg, task), colors.info(message));
},
warn: (message: string) => {
addToTaskLog(pkg, task, message);
return log.warns(getTaskLogPrefix(pkg, task), colors.warn(message));
},
error: (message: string) => {
addToTaskLog(pkg, task, message);
return log.error(getTaskLogPrefix(pkg, task), colors.error(message));
},
verbose: (message: string) => {
addToTaskLog(pkg, task, message);
return log.verbose(getTaskLogPrefix(pkg, task), colors.verbose(message));
},
};
}
export const logger = new Logger();

Просмотреть файл

@ -1,54 +0,0 @@
import { formatDuration } from "./formatDuration";
import { getTaskId } from "../task/taskId";
import { getTaskLogs, getTaskLogPrefix } from "./index";
import { logger } from ".";
import { RunContext } from "../types/RunContext";
import chalk from "chalk";
function hr() {
logger.info("----------------------------------------------");
}
export function reportSummary(context: RunContext) {
const { measures } = context;
const taskLogs = getTaskLogs();
const statusColorFn = {
success: chalk.greenBright,
failed: chalk.redBright,
skipped: chalk.gray,
};
hr();
logger.info(chalk.cyanBright(`🏗 Summary\n`));
if (measures.failedTask) {
const { pkg, task } = measures.failedTask;
const taskId = getTaskId(pkg, task);
logger.error(`ERROR DETECTED IN ${pkg} ${task}`);
logger.error(taskLogs.get(taskId)!.join("\n"));
hr();
}
if (measures.taskStats.length > 0) {
for (const stats of measures.taskStats) {
const colorFn = statusColorFn[stats.status];
logger.info(
getTaskLogPrefix(stats.pkg, stats.task),
colorFn(`${stats.status}, took ${formatDuration(stats.duration)}`)
);
}
} else {
logger.info("Nothing has been run.");
}
hr();
logger.info(
`Took a total of ${formatDuration(measures.duration)} to complete`
);
}

Просмотреть файл

@ -0,0 +1,40 @@
import { Reporter } from "./Reporter";
import { LogEntry } from "../LogEntry";
import { LogLevel } from "../LogLevel";
import { RunContext } from "../../types/RunContext";
import { hrToSeconds } from "./formatDuration";
export class JsonReporter implements Reporter {
constructor(private options: { logLevel: LogLevel }) {}
log(entry: LogEntry) {
if (this.options.logLevel >= entry.level) {
console.log(JSON.stringify(entry));
}
}
summarize(context: RunContext) {
const { measures, tasks } = context;
const summary: any = {};
const taskStats: any[] = [];
for (const task of tasks.values()) {
taskStats.push({
package: task.info.name,
task: task.task,
duration: hrToSeconds(task.duration),
status: task.status,
npmArgs: task.npmArgs,
});
}
if (measures.failedTask) {
summary.failedTask = measures.failedTask;
}
summary.duration = hrToSeconds(measures.duration);
summary.taskStats = taskStats;
console.log(JSON.stringify({ summary }));
}
}

Просмотреть файл

@ -0,0 +1,196 @@
import log from "npmlog";
import chalk from "chalk";
import { Reporter } from "./Reporter";
import { LogLevel } from "../LogLevel";
import { LogEntry } from "../LogEntry";
import { formatDuration, hrToSeconds } from "./formatDuration";
import { getTaskId } from "../../task/taskId";
import { RunContext } from "../../types/RunContext";
const maxLengths = {
pkg: 0,
task: 0,
};
const colors = {
info: chalk.white,
verbose: chalk.gray,
warn: chalk.white,
error: chalk.white,
task: chalk.cyan,
pkg: chalk.magenta,
};
function getTaskLogPrefix(pkg: string, task: string) {
return `${colors.pkg(pkg.padStart(maxLengths.pkg))} ${colors.task(
task.padStart(maxLengths.task)
)}`;
}
function normalize(prefixOrMessage: string, message?: string) {
if (typeof message === "string") {
const prefix = prefixOrMessage;
return { prefix, message };
} else {
const prefix = "";
const message = prefixOrMessage;
return { prefix, message };
}
}
export class NpmLogReporter implements Reporter {
readonly groupedEntries = new Map<string, LogEntry[]>();
constructor(private options: { logLevel?: LogLevel; grouped?: boolean }) {
options.logLevel = options.logLevel || LogLevel.info;
log.level = LogLevel[options.logLevel];
}
log(entry: LogEntry) {
if (this.options.logLevel! >= entry.level) {
const isTaskLogEntry =
entry.data && entry.data.package && entry.data.task;
if (isTaskLogEntry && !this.options.grouped) {
return this.logTaskEntry(
entry.data!.package!,
entry.data!.task!,
entry
);
} else if (isTaskLogEntry && this.options.grouped) {
return this.logTaskEntryInGroup(
entry.data!.package!,
entry.data!.task!,
entry
);
} else {
return this.logGenericEntry(entry);
}
}
}
private logGenericEntry(entry: LogEntry) {
const normalizedArgs = normalize(entry.msg);
const logFn = log[LogLevel[entry.level]];
const colorFn = colors[LogLevel[entry.level]];
return logFn(normalizedArgs.prefix, colorFn(normalizedArgs.message));
}
private logTaskEntry(pkg: string, task: string, entry: LogEntry) {
const normalizedArgs = this.options.grouped
? normalize(entry.msg)
: normalize(getTaskLogPrefix(pkg, task), entry.msg);
const logFn = log[LogLevel[entry.level]];
const colorFn = colors[LogLevel[entry.level]];
if (entry.data && entry.data.status) {
const pkgTask = this.options.grouped
? `${chalk.magenta(pkg)} ${chalk.cyan(task)}`
: "";
switch (entry.data.status) {
case "started":
return logFn(normalizedArgs.prefix, colorFn(`▶️ start ${pkgTask}`));
case "completed":
return logFn(
normalizedArgs.prefix,
colorFn(
`✔️ done ${pkgTask} - ${formatDuration(entry.data.duration!)}`
)
);
case "failed":
return logFn(normalizedArgs.prefix, colorFn(`❌ fail ${pkgTask}`));
case "skipped":
return logFn(normalizedArgs.prefix, colorFn(`⏭️ skip ${pkgTask}`));
}
} else {
return logFn(
normalizedArgs.prefix,
colorFn("| " + normalizedArgs.message)
);
}
}
private logTaskEntryInGroup(pkg: string, task: string, logEntry: LogEntry) {
const taskId = getTaskId(pkg, task);
this.groupedEntries.set(taskId, this.groupedEntries.get(taskId) || []);
this.groupedEntries.get(taskId)?.push(logEntry);
if (
logEntry.data &&
(logEntry.data.status === "completed" ||
logEntry.data.status === "failed" ||
logEntry.data.status === "skipped")
) {
const entries = this.groupedEntries.get(taskId)!;
for (const entry of entries) {
this.logTaskEntry(entry.data?.package!, entry.data?.task!, entry);
}
this.hr();
}
}
hr() {
log.info("", "----------------------------------------------");
}
summarize(context: RunContext) {
const { measures, tasks } = context;
const { hr } = this;
const statusColorFn = {
completed: chalk.greenBright,
failed: chalk.redBright,
skipped: chalk.gray,
};
log.info("", chalk.cyanBright(`🏗 Summary\n`));
if (measures.failedTask) {
const { pkg, task } = measures.failedTask;
const taskId = getTaskId(pkg, task);
const taskLogs = tasks.get(taskId)?.logger.getLogs();
log.error("", `ERROR DETECTED IN ${pkg} ${task}`);
if (taskLogs) {
log.error("", taskLogs?.map((entry) => entry.msg).join("\n"));
}
hr();
}
if (tasks.size > 0) {
for (const npmScriptTask of tasks.values()) {
const colorFn = statusColorFn[npmScriptTask.status];
log.info(
"",
getTaskLogPrefix(npmScriptTask.info.name, npmScriptTask.task),
colorFn(
`${npmScriptTask.status}, took ${formatDuration(
hrToSeconds(npmScriptTask.duration)
)}`
)
);
}
} else {
log.info("", "Nothing has been run.");
}
hr();
log.info(
"",
`Took a total of ${formatDuration(
hrToSeconds(measures.duration)
)} to complete`
);
}
}

Просмотреть файл

@ -0,0 +1,7 @@
import { LogEntry } from "../LogEntry";
import { RunContext } from "../../types/RunContext";
export interface Reporter {
log(entry: LogEntry): void;
summarize(context: RunContext): void;
}

Просмотреть файл

@ -1,5 +1,5 @@
export function formatDuration(hrtime: [number, number]) {
let raw = hrtime[0] + hrtime[1] / 1e9;
export function formatDuration(seconds: string) {
let raw = parseFloat(seconds);
if (raw > 60) {
const minutes = Math.floor(raw / 60);
const seconds = (raw - minutes * 60).toFixed(2);
@ -9,3 +9,8 @@ export function formatDuration(hrtime: [number, number]) {
return `${seconds}s`;
}
}
export function hrToSeconds(hrtime: [number, number]) {
let raw = hrtime[0] + hrtime[1] / 1e9;
return raw.toFixed(2);
}

179
src/task/NpmScriptTask.ts Normal file
Просмотреть файл

@ -0,0 +1,179 @@
import { TaskLogger } from "../logger/TaskLogger";
import { ChildProcess } from "child_process";
import { PackageInfo } from "workspace-tools";
import { Config } from "../types/Config";
import { findNpmClient } from "../workspace/findNpmClient";
import { spawn } from "child_process";
import { controller } from "./abortSignal";
import path from "path";
import { TaskLogWritable } from "../logger/TaskLogWritable";
import { cacheHash, cacheFetch, cachePut } from "../cache/backfill";
import { RunContext } from "../types/RunContext";
import { hrToSeconds } from "../logger/reporters/formatDuration";
export class NpmScriptTask {
static npmCmd: string = "";
static bail = false;
static activeProcesses = new Set<ChildProcess>();
npmArgs: string[] = [];
startTime: [number, number] = [0, 0];
duration: [number, number] = [0, 0];
status: "completed" | "failed" | "pending" | "started" | "skipped";
logger: TaskLogger;
static killAllActiveProcesses() {
for (const cp of NpmScriptTask.activeProcesses) {
cp.kill("SIGKILL");
}
}
constructor(
public task: string,
private root: string,
public info: PackageInfo,
private config: Config,
private context: RunContext
) {
NpmScriptTask.npmCmd =
NpmScriptTask.npmCmd || findNpmClient(config.npmClient);
this.status = "pending";
this.logger = new TaskLogger(info.name, task);
const { node, args } = config;
this.npmArgs = [...node, "run", task, "--", ...args];
}
onStart() {
this.status = "started";
this.startTime = process.hrtime();
this.logger.info("started", { status: "started" });
}
onComplete() {
this.status = "completed";
this.duration = process.hrtime(this.startTime);
this.logger.info("completed", {
status: "completed",
duration: hrToSeconds(this.duration),
});
}
onFail() {
this.status = "failed";
this.duration = process.hrtime(this.startTime);
this.logger.info("failed", {
status: "failed",
duration: hrToSeconds(this.duration),
});
}
onSkipped() {
this.status = "skipped";
this.duration = process.hrtime(this.startTime);
this.logger.info("skipped", {
status: "skipped",
duration: hrToSeconds(this.duration),
});
}
async getCache() {
let hash: string | null = null;
let cacheHit = false;
const { task, info, root, config, logger } = this;
if (config.cache) {
hash = await cacheHash(task, info, root, config);
if (hash && !config.resetCache) {
cacheHit = await cacheFetch(hash, info, config);
}
logger.verbose(`hash: ${hash}, cache hit? ${cacheHit}`);
}
return { hash, cacheHit };
}
async saveCache(hash: string) {
const { logger, info, config } = this;
logger.verbose(`hash put ${hash}`);
await cachePut(hash, info, config);
}
runScript() {
const { info, logger, npmArgs } = this;
const { npmCmd } = NpmScriptTask;
return new Promise((resolve, reject) => {
logger.verbose(`Running ${[npmCmd, ...npmArgs].join(" ")}`);
const cp = spawn(npmCmd, npmArgs, {
cwd: path.dirname(info.packageJsonPath),
stdio: "pipe",
env: {
...process.env,
...(process.stdout.isTTY && { FORCE_COLOR: "1" }),
LAGE_PACKAGE_NAME: info.name,
},
});
NpmScriptTask.activeProcesses.add(cp);
const stdoutLogger = new TaskLogWritable(this.logger);
cp.stdout.pipe(stdoutLogger);
const stderrLogger = new TaskLogWritable(this.logger);
cp.stderr.pipe(stderrLogger);
cp.on("exit", handleChildProcessExit);
function handleChildProcessExit(code: number) {
if (code === 0) {
NpmScriptTask.activeProcesses.delete(cp);
return resolve();
}
NpmScriptTask.bail = true;
cp.stdout.destroy();
cp.stdin.destroy();
reject();
}
});
}
async run() {
const { info, task, context, config } = this;
this.onStart();
try {
const { hash, cacheHit } = await this.getCache();
// skip if cache hit!
if (cacheHit) {
this.onSkipped();
return true;
}
await context.profiler.run(
() => this.runScript(),
`${info.name}.${task}`
);
if (config.cache && hash) {
await this.saveCache(hash);
}
this.onComplete();
} catch (e) {
context.measures.failedTask = { pkg: info.name, task };
controller.abort();
this.onFail();
return false;
}
return true;
}
}

Просмотреть файл

@ -18,12 +18,12 @@ export function filterPackages(options: {
// If scoped is defined, get scoped packages
if (typeof scopedPackages !== "undefined") {
filtered = filtered.filter((pkg) => scopedPackages.includes(pkg));
logger.verbose("filterPackages", `scope: ${scopedPackages.join(",")}`);
logger.verbose(`filterPackages scope: ${scopedPackages.join(",")}`);
}
if (typeof changedPackages !== "undefined") {
filtered = filtered.filter((pkg) => changedPackages.includes(pkg));
logger.verbose("filterPackages", `changed: ${changedPackages.join(",")}`);
logger.verbose(`filterPackages changed: ${changedPackages.join(",")}`);
}
if (deps) {

Просмотреть файл

@ -1,88 +0,0 @@
import { Config } from "../types/Config";
import { findNpmClient } from "../workspace/findNpmClient";
import { PackageInfo } from "workspace-tools";
import { RunContext } from "../types/RunContext";
import { spawn, ChildProcess } from "child_process";
import { taskLogger, NpmLogWritable } from "../logger";
import { taskWrapper } from "./taskWrapper";
import path from "path";
let npmCmd: string;
let bail = false;
let activeProcesses = new Set<ChildProcess>();
export function npmTask(
task: string,
info: PackageInfo,
config: Config,
context: RunContext,
root: string
) {
const { node, args, npmClient } = config;
const logger = taskLogger(info.name, task);
// cached npmCmd
npmCmd = npmCmd || findNpmClient(npmClient);
const npmArgs = [...node, "run", task, "--", ...args];
if (bail) {
return Promise.reject();
}
return taskWrapper(
info,
task,
() =>
new Promise((resolve, reject) => {
if (!info.scripts || !info.scripts![task]) {
logger.info(`Empty script detected, skipping`);
return resolve();
}
logger.verbose(`Running ${[npmCmd, ...npmArgs].join(" ")}`);
const cp = spawn(npmCmd, npmArgs, {
cwd: path.dirname(info.packageJsonPath),
stdio: "pipe",
env: {
...process.env,
...(process.stdout.isTTY && { FORCE_COLOR: "1" }),
LAGE_PACKAGE_NAME: info.name,
},
});
activeProcesses.add(cp);
const stdoutLogger = new NpmLogWritable(info.name, task);
cp.stdout.pipe(stdoutLogger);
const stderrLogger = new NpmLogWritable(info.name, task);
cp.stderr.pipe(stderrLogger);
cp.on("exit", handleChildProcessExit);
function handleChildProcessExit(code: number) {
if (code === 0) {
activeProcesses.delete(cp);
return resolve();
}
bail = true;
cp.stdout.destroy();
cp.stdin.destroy();
reject();
}
}),
config,
context,
root
);
}
export function killAllActiveProcesses() {
for (const cp of activeProcesses) {
cp.kill("SIGKILL");
}
}

Просмотреть файл

@ -5,16 +5,16 @@ import {
Task,
} from "@microsoft/task-scheduler";
import { Config } from "../types/Config";
import { npmTask } from "./npmTask";
import { filterPackages } from "./filterPackages";
import { Workspace } from "../types/Workspace";
import { setTaskLogMaxLengths } from "../logger";
import {
getScopedPackages,
getChangedPackages,
getTransitiveProviders,
} from "workspace-tools";
import { Priority } from "../types/Priority";
import { NpmScriptTask } from "./NpmScriptTask";
import { getTaskId } from "./taskId";
/** Returns a map that maps task name to the priorities config for that task */
function getPriorityMap(priorities: Priority[]) {
@ -50,29 +50,32 @@ export async function runTasks(options: {
concurrency: config.concurrency,
});
const taskNames = Object.keys(config.pipeline);
for (const [task, taskDeps] of Object.entries(config.pipeline)) {
for (const [taskName, taskDeps] of Object.entries(config.pipeline)) {
const deps = taskDeps.filter((dep) => !dep.startsWith("^"));
const topoDeps = taskDeps
.filter((dep) => dep.startsWith("^"))
.map((dep) => dep.slice(1));
pipeline = pipeline.addTask({
name: task,
name: taskName,
deps,
topoDeps,
priorities: priorityMap.get(task),
priorities: priorityMap.get(taskName),
run: async (_location, _stdout, _stderr, pkg) => {
const scripts = workspace.allPackages[pkg].scripts;
if (scripts && scripts[task]) {
return (await npmTask(
task,
workspace.allPackages[pkg],
const info = workspace.allPackages[pkg];
const scripts = info.scripts;
if (scripts && scripts[taskName]) {
const npmTask = new NpmScriptTask(
taskName,
workspace.root,
info,
config,
context,
workspace.root
)) as boolean;
context
);
context.tasks.set(getTaskId(info.name, taskName), npmTask);
return await npmTask.run();
}
return true;
},
@ -95,6 +98,7 @@ export async function runTasks(options: {
const hasSince = typeof since !== "undefined";
let changedPackages: string[] | undefined = undefined;
if (hasSince) {
changedPackages = getChangedPackages(workspace.root, since, config.ignore);
}
@ -106,15 +110,6 @@ export async function runTasks(options: {
changedPackages,
});
// Set up the longest names of tasks and scripts for nice logging
setTaskLogMaxLengths(
Object.keys(workspace.allPackages).reduce(
(l, pkg) => (l < pkg.length ? pkg.length : l),
0
),
taskNames.reduce((l, task) => (l < task.length ? task.length : l), 0)
);
await pipeline.go({
packages: filteredPackages,
tasks: config.command,

Просмотреть файл

@ -1,78 +0,0 @@
import { cacheHash, cacheFetch, cachePut } from "../cache/backfill";
import { formatDuration } from "../logger/formatDuration";
import { taskLogger } from "../logger";
import { RunContext } from "../types/RunContext";
import { Config } from "../types/Config";
import { PackageInfo } from "workspace-tools";
import { controller } from "./abortSignal";
export async function taskWrapper(
info: PackageInfo,
task: string,
fn: () => Promise<unknown>,
config: Config,
context: RunContext,
root: string
): Promise<unknown> {
const { profiler, measures } = context;
const pkg = info.name;
const logger = taskLogger(pkg, task);
const start = process.hrtime();
let cacheHit = false;
let hash: string | null = null;
if (config.cache) {
hash = await cacheHash(task, info, root, config);
if (hash && !config.resetCache) {
cacheHit = await cacheFetch(hash, info, config);
}
logger.verbose(`hash: ${hash}, cache hit? ${cacheHit}`);
}
if (!cacheHit) {
logger.info("▶️ start");
try {
await profiler.run(() => fn(), `${pkg}.${task}`);
const duration = process.hrtime(start);
measures.taskStats.push({
pkg,
task,
start,
duration,
status: "success",
});
logger.info(`✔️ done - took ${formatDuration(duration)}`);
if (config.cache && hash) {
logger.verbose(`hash put ${hash}`);
await cachePut(hash, info, config);
}
return true;
} catch (e) {
handleFailure();
return false;
}
} else {
const duration = process.hrtime(start);
measures.taskStats.push({ pkg, task, start, duration, status: "skipped" });
logger.info("⏭️ skip");
return true;
}
function handleFailure() {
logger.info("❌ fail");
if (!measures.failedTask) {
measures.failedTask = { pkg, task };
}
const duration = process.hrtime(start);
measures.taskStats.push({ pkg, task, start, duration, status: "failed" });
controller.abort();
}
}

Просмотреть файл

@ -121,4 +121,18 @@ export interface CliOptions {
* has changed with the `--since` flag.
*/
ignore: string[];
/**
* Specify whether to use the JSON Reporter to create a parsable log output
*
* Example: `lage --reporter json`
*/
reporter: string;
/**
* Specify whether to make the console logger to group the logs per package task
*
* Example: `lage --grouped`
*/
grouped: boolean;
}

Просмотреть файл

@ -1,21 +1,14 @@
import Profiler from "p-profiler";
import { NpmScriptTask } from "../task/NpmScriptTask";
interface TaskStats {
pkg: string;
task: string;
start: [number, number];
duration: [number, number];
status: "failed" | "skipped" | "success" | "not started";
}
interface Measures {
export interface Measures {
start: [number, number];
duration: [number, number];
failedTask?: { pkg: string; task: string };
taskStats: TaskStats[];
}
export interface RunContext {
measures: Measures;
tasks: Map<string, NpmScriptTask>;
profiler: Profiler;
}

Просмотреть файл

@ -1,10 +0,0 @@
export type TaskId = string;
export type TaskLogs = Map<TaskId, string[]>;
export type TaskLogger = {
info: (message: string, ...args: any) => void;
warn: (message: string, ...args: any) => any;
error: (message: string, ...args: any) => void;
verbose: (message: string, ...args: any) => void;
};

1
src/types/TaskId.ts Normal file
Просмотреть файл

@ -0,0 +1 @@
export type TaskId = string;

Просмотреть файл

@ -1,4 +1,5 @@
import { Monorepo } from "../mock/monorepo";
import { parseNdJson } from "./parseNdJson";
describe("basics", () => {
it("basic test case", () => {
@ -13,12 +14,47 @@ describe("basics", () => {
const results = repo.run("test");
const output = results.stdout + results.stderr;
const jsonOutput = parseNdJson(output);
expect(output).toContain("b build success");
expect(output).toContain("a build success");
expect(output).toContain("a test success");
expect(output).toContain("b test success");
expect(
jsonOutput.find((entry) =>
filterEntry(entry.data, "b", "build", "completed")
)
).toBeTruthy();
expect(
jsonOutput.find((entry) =>
filterEntry(entry.data, "b", "test", "completed")
)
).toBeTruthy();
expect(
jsonOutput.find((entry) =>
filterEntry(entry.data, "a", "build", "completed")
)
).toBeTruthy();
expect(
jsonOutput.find((entry) =>
filterEntry(entry.data, "a", "test", "completed")
)
).toBeTruthy();
expect(
jsonOutput.find((entry) =>
filterEntry(entry.data, "a", "lint", "completed")
)
).toBeFalsy();
repo.cleanup();
});
});
function filterEntry(taskData, pkg, task, status) {
return (
taskData &&
taskData.package === pkg &&
taskData.task === task &&
taskData.status === status
);
}

94
tests/e2e/bigapp.test.ts Normal file
Просмотреть файл

@ -0,0 +1,94 @@
import { Monorepo } from "../mock/monorepo";
import { getTaskId } from "../../src/task/taskId";
import { parseNdJson } from "./parseNdJson";
describe("bigapp test", () => {
// This test follows the model as documented here:
// https://microsoft.github.io/lage/guide/levels.html
it("with apps and libs and all, y'all", () => {
const repo = new Monorepo("bigapp");
repo.init();
repo.install();
repo.addPackage("FooApp1", ["FooCore"]);
repo.addPackage("FooApp2", ["FooCore"]);
repo.addPackage("FooCore", ["BuildTool"]);
repo.addPackage("BarPage", ["BarCore"]);
repo.addPackage("BarCore", ["BuildTool"]);
repo.addPackage("BuildTool");
repo.linkPackages();
const results = repo.run("test");
const output = results.stdout + results.stderr;
const jsonOutput = parseNdJson(output);
const indices: { [taskId: string]: number } = {};
for (const pkg of [
"FooApp1",
"FooApp2",
"FooCore",
"BarCore",
"BarPage",
"BuildTool",
]) {
for (const task of ["build", "test"]) {
indices[getTaskId(pkg, task)] = jsonOutput.findIndex((e) =>
filterEntry(e.data, pkg, task, "completed")
);
}
}
expect(indices[getTaskId("BuildTool", "build")]).toBeLessThan(
indices[getTaskId("BuildTool", "test")]
);
expect(indices[getTaskId("BuildTool", "build")]).toBeLessThan(
indices[getTaskId("FooCore", "build")]
);
expect(indices[getTaskId("BuildTool", "build")]).toBeLessThan(
indices[getTaskId("FooApp1", "build")]
);
expect(indices[getTaskId("BuildTool", "build")]).toBeLessThan(
indices[getTaskId("FooApp2", "build")]
);
expect(indices[getTaskId("BuildTool", "build")]).toBeLessThan(
indices[getTaskId("BarPage", "build")]
);
expect(indices[getTaskId("BuildTool", "build")]).toBeLessThan(
indices[getTaskId("BarCore", "build")]
);
expect(indices[getTaskId("BarCore", "build")]).toBeLessThan(
indices[getTaskId("BarPage", "build")]
);
expect(indices[getTaskId("FooCore", "build")]).toBeLessThan(
indices[getTaskId("FooApp2", "build")]
);
expect(indices[getTaskId("FooCore", "build")]).toBeLessThan(
indices[getTaskId("FooCore", "test")]
);
expect(indices[getTaskId("BarPage", "build")]).toBeLessThan(
indices[getTaskId("BarPage", "test")]
);
repo.cleanup();
});
});
function filterEntry(taskData, pkg, task, status) {
return (
taskData &&
taskData.package === pkg &&
taskData.task === task &&
taskData.status === status
);
}

9
tests/e2e/parseNdJson.ts Normal file
Просмотреть файл

@ -0,0 +1,9 @@
export function parseNdJson(ndjson: string) {
return ndjson.split("\n").map((line) => {
try {
return JSON.parse(line);
} catch (e) {
return {};
}
});
}

Просмотреть файл

@ -78,6 +78,8 @@ export class Monorepo {
}
generateRepoFiles() {
const lagePath = path.join(this.nodeModulesPath, "lage/lib/index");
this.commitFiles({
"package.json": {
name: this.name,
@ -85,9 +87,9 @@ export class Monorepo {
private: true,
workspaces: ["packages/*"],
scripts: {
build: "lage build",
test: "lage test",
lint: "lage lint",
build: `node "${lagePath}" build --reporter json`,
test: `node "${lagePath}" test --reporter json`,
lint: `node "${lagePath}" lint --reporter json`,
},
devDependencies: {
lage: path.resolve(__dirname, "..", ".."),
@ -101,34 +103,6 @@ export class Monorepo {
}
};`,
});
this.commitFiles(
{
"node_modules/.bin/lage": `#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')")
case \`uname\` in
*CYGWIN*) basedir=\`cygpath -w "$basedir"\`;;
esac
if [ -x "$basedir/node" ]; then
"$basedir/node" "$basedir/../lage/bin/lage.js" "$@"
ret=$?
else
node "$basedir/../lage/bin/lage.js" "$@"
ret=$?
fi
exit $ret`,
"node_modules/.bin/lage.cmd": `@IF EXIST "%~dp0\node.exe" (
"%~dp0\\node.exe" "%~dp0\\..\\lage\\bin\\lage.js" %*
) ELSE (
@SETLOCAL
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\\..\\lage\\bin\\lage.js" %*
)`,
},
{ executable: true }
);
}
addPackage(name: string, internalDeps: string[] = []) {

Просмотреть файл

@ -1,10 +1,4 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"lage/src/*": ["../src/*"]
}
},
"include": [".", "../src"]
}