@vercel/devlow-bench 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ import type { Page } from "playwright-chromium";
2
+ interface BrowserSession {
3
+ close(): Promise<void>;
4
+ hardNavigation(url: string, metricName?: string): Promise<Page>;
5
+ softNavigationByClick(metricName: string, selector: string): Promise<void>;
6
+ reload(metricName: string): Promise<void>;
7
+ }
8
+ export declare function newBrowserSession(options: {
9
+ headless?: boolean;
10
+ devtools?: boolean;
11
+ baseURL?: string;
12
+ }): Promise<BrowserSession>;
13
+ export {};
@@ -0,0 +1,178 @@
1
+ import { chromium } from "playwright-chromium";
2
+ import { measureTime, reportMeasurement } from "./index.js";
3
+ async function withRequestMetrics(metricName, page, fn) {
4
+ const activePromises = [];
5
+ const sizeByExtension = new Map();
6
+ const requestsByExtension = new Map();
7
+ const responseHandler = (response) => {
8
+ activePromises.push((async () => {
9
+ const url = response.request().url();
10
+ const status = response.status();
11
+ const extension = /^[^\?#]+\.([a-z0-9]+)(?:[\?#]|$)/i.exec(url)?.[1] ?? "none";
12
+ const currentRequests = requestsByExtension.get(extension) ?? 0;
13
+ requestsByExtension.set(extension, currentRequests + 1);
14
+ if (status >= 200 && status < 300) {
15
+ let body;
16
+ try {
17
+ body = await response.body();
18
+ }
19
+ catch { }
20
+ if (body) {
21
+ const size = body.length;
22
+ const current = sizeByExtension.get(extension) ?? 0;
23
+ sizeByExtension.set(extension, current + size);
24
+ }
25
+ }
26
+ })());
27
+ };
28
+ try {
29
+ page.on("response", responseHandler);
30
+ await fn();
31
+ await Promise.all(activePromises);
32
+ let totalDownload = 0;
33
+ for (const [extension, size] of sizeByExtension.entries()) {
34
+ reportMeasurement(`${metricName}/responseSizes/${extension}`, size, "bytes");
35
+ totalDownload += size;
36
+ }
37
+ reportMeasurement(`${metricName}/responseSizes`, totalDownload, "bytes");
38
+ let totalRequests = 0;
39
+ for (const [extension, count] of requestsByExtension.entries()) {
40
+ reportMeasurement(`${metricName}/requests/${extension}`, count, "requests");
41
+ totalRequests += count;
42
+ }
43
+ reportMeasurement(`${metricName}/requests`, totalRequests, "requests");
44
+ }
45
+ finally {
46
+ page.off("response", responseHandler);
47
+ }
48
+ }
49
+ function networkIdle(page) {
50
+ return new Promise((resolve) => {
51
+ const cleanup = () => {
52
+ page.off("request", requestHandler);
53
+ page.off("requestfailed", requestFinishedHandler);
54
+ page.off("requestfinished", requestFinishedHandler);
55
+ };
56
+ let activeRequests = 0;
57
+ let timeout = null;
58
+ const requestHandler = (request) => {
59
+ activeRequests++;
60
+ if (timeout) {
61
+ clearTimeout(timeout);
62
+ timeout = null;
63
+ }
64
+ };
65
+ const requestFinishedHandler = (request) => {
66
+ activeRequests--;
67
+ if (activeRequests === 0) {
68
+ timeout = setTimeout(() => {
69
+ cleanup();
70
+ resolve();
71
+ }, 300);
72
+ }
73
+ };
74
+ page.on("request", requestHandler);
75
+ page.on("requestfailed", requestFinishedHandler);
76
+ page.on("requestfinished", requestFinishedHandler);
77
+ });
78
+ }
79
+ class BrowserSessionImpl {
80
+ constructor(browser, context) {
81
+ this.browser = browser;
82
+ this.context = context;
83
+ this.page = null;
84
+ }
85
+ async close() {
86
+ if (this.page) {
87
+ await this.page.close();
88
+ }
89
+ await this.context.close();
90
+ await this.browser.close();
91
+ }
92
+ async hardNavigation(metricName, url) {
93
+ const page = (this.page = this.page ?? (await this.context.newPage()));
94
+ await withRequestMetrics(metricName, page, async () => {
95
+ measureTime(`${metricName}/start`);
96
+ await page.goto(url, {
97
+ waitUntil: "commit",
98
+ });
99
+ measureTime(`${metricName}/html`, {
100
+ relativeTo: `${metricName}/start`,
101
+ });
102
+ await page.waitForLoadState("domcontentloaded");
103
+ measureTime(`${metricName}/dom`, {
104
+ relativeTo: `${metricName}/start`,
105
+ });
106
+ await page.waitForLoadState("load");
107
+ measureTime(`${metricName}/load`, {
108
+ relativeTo: `${metricName}/start`,
109
+ });
110
+ await page.waitForLoadState("networkidle");
111
+ measureTime(`${metricName}`, {
112
+ relativeTo: `${metricName}/start`,
113
+ });
114
+ });
115
+ return page;
116
+ }
117
+ async softNavigationByClick(metricName, selector) {
118
+ const page = this.page;
119
+ if (!page) {
120
+ throw new Error("softNavigationByClick() must be called after hardNavigation()");
121
+ }
122
+ await withRequestMetrics(metricName, page, async () => {
123
+ measureTime(`${metricName}/start`);
124
+ const firstResponse = new Promise((resolve) => page.once("response", () => resolve()));
125
+ const idle = networkIdle(page);
126
+ await page.click(selector);
127
+ await firstResponse;
128
+ measureTime(`${metricName}/firstResponse`, {
129
+ relativeTo: `${metricName}/start`,
130
+ });
131
+ await idle;
132
+ measureTime(`${metricName}`, {
133
+ relativeTo: `${metricName}/start`,
134
+ });
135
+ });
136
+ }
137
+ async reload(metricName) {
138
+ const page = this.page;
139
+ if (!page) {
140
+ throw new Error("reload() must be called after hardNavigation()");
141
+ }
142
+ await withRequestMetrics(metricName, page, async () => {
143
+ measureTime(`${metricName}/start`);
144
+ await page.reload({
145
+ waitUntil: "commit",
146
+ });
147
+ measureTime(`${metricName}/html`, {
148
+ relativeTo: `${metricName}/start`,
149
+ });
150
+ await page.waitForLoadState("domcontentloaded");
151
+ measureTime(`${metricName}/dom`, {
152
+ relativeTo: `${metricName}/start`,
153
+ });
154
+ await page.waitForLoadState("load");
155
+ measureTime(`${metricName}/load`, {
156
+ relativeTo: `${metricName}/start`,
157
+ });
158
+ await page.waitForLoadState("networkidle");
159
+ measureTime(`${metricName}`, {
160
+ relativeTo: `${metricName}/start`,
161
+ });
162
+ });
163
+ }
164
+ }
165
+ export async function newBrowserSession(options) {
166
+ const browser = await chromium.launch({
167
+ headless: options.headless ?? process.env.HEADLESS !== "false",
168
+ timeout: 60000,
169
+ });
170
+ const context = await browser.newContext({
171
+ baseURL: options.baseURL ?? "http://localhost:3000",
172
+ viewport: { width: 1280, height: 720 },
173
+ });
174
+ context.setDefaultTimeout(120000);
175
+ context.setDefaultNavigationTimeout(120000);
176
+ return new BrowserSessionImpl(browser, context);
177
+ }
178
+ // see next.js/test/lib/browsers/playwright.ts
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,88 @@
1
+ import minimist from "minimist";
2
+ import { setCurrentScenarios } from "./describe.js";
3
+ import { join } from "path";
4
+ import { runScenarios } from "./index.js";
5
+ import compose from "./interfaces/compose.js";
6
+ import { pathToFileURL } from "url";
7
+ (async () => {
8
+ const knownArgs = new Set([
9
+ "scenario",
10
+ "s",
11
+ "json",
12
+ "j",
13
+ "console",
14
+ "datadog",
15
+ "interactive",
16
+ "i",
17
+ "help",
18
+ "h",
19
+ "?",
20
+ "_",
21
+ ]);
22
+ const args = minimist(process.argv.slice(2), {
23
+ alias: {
24
+ s: "scenario",
25
+ j: "json",
26
+ i: "interactive",
27
+ "?": "help",
28
+ h: "help",
29
+ },
30
+ });
31
+ if (args.help || (Object.keys(args).length === 1 && args._.length === 0)) {
32
+ console.log("Usage: devlow-bench [options] <scenario files>");
33
+ console.log("## Selecting scenarios");
34
+ console.log(" --scenario=<filter>, -s=<filter> Only run the scenario with the given name");
35
+ console.log(" --interactive, -i Select scenarios and variants interactively");
36
+ console.log(" --<prop>=<value> Filter by any variant property defined in scenarios");
37
+ console.log("## Output");
38
+ console.log(" --json=<path>, -j=<path> Write the results to the given path as JSON");
39
+ console.log(" --console Print the results to the console");
40
+ console.log(" --datadog[=<hostname>] Upload the results to Datadog");
41
+ console.log(" (requires DATADOG_API_KEY environment variables)");
42
+ console.log("## Help");
43
+ console.log(" --help, -h, -? Show this help");
44
+ }
45
+ const scenarios = [];
46
+ setCurrentScenarios(scenarios);
47
+ for (const path of args._) {
48
+ await import(pathToFileURL(join(process.cwd(), path)).toString());
49
+ }
50
+ setCurrentScenarios(null);
51
+ const cliIface = {
52
+ filterScenarios: async (scenarios) => {
53
+ if (args.scenario) {
54
+ const filter = [].concat(args.scenario);
55
+ return scenarios.filter((s) => filter.some((filter) => s.name.includes(filter)));
56
+ }
57
+ return scenarios;
58
+ },
59
+ filterScenarioVariants: async (variants) => {
60
+ const propEntries = Object.entries(args).filter(([key]) => !knownArgs.has(key));
61
+ if (propEntries.length === 0)
62
+ return variants;
63
+ for (const [key, value] of propEntries) {
64
+ const values = (Array.isArray(value) ? value : [value]).map((v) => v.toString());
65
+ variants = variants.filter((variant) => {
66
+ const prop = variant.props[key];
67
+ if (typeof prop === "undefined")
68
+ return false;
69
+ const str = prop.toString();
70
+ return values.some((v) => str.includes(v));
71
+ });
72
+ }
73
+ return variants;
74
+ },
75
+ };
76
+ let ifaces = [
77
+ cliIface,
78
+ args.interactive && (await import("./interfaces/interactive.js")).default(),
79
+ args.json && (await import("./interfaces/json.js")).default(args.json),
80
+ args.datadog &&
81
+ (await import("./interfaces/datadog.js")).default(typeof args.datadog === "string" ? { host: args.datadog } : undefined),
82
+ args.console !== false &&
83
+ (await import("./interfaces/console.js")).default(),
84
+ ].filter((x) => x);
85
+ await runScenarios(scenarios, compose(...ifaces));
86
+ })().catch((e) => {
87
+ console.error(e.stack);
88
+ });
@@ -0,0 +1,18 @@
1
+ import type { ConfigFor, CurrentScenario, Scenario } from "./index.js";
2
+ export declare function setCurrentScenarios(scenarios: Scenario[] | null): void;
3
+ export declare function describe<P>(name: string, config: ConfigFor<P>, fn: (props: P) => Promise<void>): void;
4
+ export declare namespace describe {
5
+ var only: <P>(name: string, config: ConfigFor<P>, fn: (props: P) => Promise<void>) => void;
6
+ }
7
+ export declare function withCurrent(current: CurrentScenario, fn: () => Promise<void>): Promise<void>;
8
+ export declare const PREVIOUS: unique symbol;
9
+ export declare function measureTime(name: string, options?: {
10
+ relativeTo?: string | typeof PREVIOUS;
11
+ scenario?: string;
12
+ props?: Record<string, string | number | null>;
13
+ }): void;
14
+ export declare function reportMeasurement(name: string, value: number, unit: string, options?: {
15
+ relativeTo?: string | typeof PREVIOUS;
16
+ scenario?: string;
17
+ props?: Record<string, string | number | null>;
18
+ }): void;
@@ -0,0 +1,97 @@
1
+ import compose from "./interfaces/compose.js";
2
+ import { runScenarios } from "./runner.js";
3
+ let currentScenarios = null;
4
+ export function setCurrentScenarios(scenarios) {
5
+ currentScenarios = scenarios;
6
+ }
7
+ export function describe(name, config, fn) {
8
+ if (currentScenarios === null) {
9
+ const scenarios = (currentScenarios = []);
10
+ Promise.resolve().then(async () => {
11
+ const ifaceNames = process.env.INTERFACE || "interactive,console";
12
+ const ifaces = [];
13
+ for (const ifaceName of ifaceNames.split(",").map((s) => s.trim())) {
14
+ let iface;
15
+ try {
16
+ iface = await import(`./interfaces/${ifaceName}.js`);
17
+ }
18
+ catch (e) {
19
+ iface = await import(ifaceName);
20
+ }
21
+ iface = (iface && iface.default) || iface;
22
+ if (typeof iface === "function") {
23
+ iface = await iface();
24
+ }
25
+ if (!iface) {
26
+ throw new Error(`Interface ${ifaceName} is not a valid interface`);
27
+ }
28
+ ifaces.push(iface);
29
+ }
30
+ runScenarios(scenarios, compose(...ifaces));
31
+ });
32
+ }
33
+ const normalizedConfig = Object.fromEntries(Object.entries(config).map(([key, value]) => [
34
+ key,
35
+ typeof value === "boolean"
36
+ ? [value, !value]
37
+ : value,
38
+ ]));
39
+ currentScenarios.push({
40
+ name,
41
+ config: normalizedConfig,
42
+ only: false,
43
+ fn: fn,
44
+ });
45
+ }
46
+ describe.only = function describeOnly(name, config, fn) {
47
+ describe(name, config, fn);
48
+ currentScenarios[currentScenarios.length - 1].only = true;
49
+ };
50
+ let currentScenario = null;
51
+ export function withCurrent(current, fn) {
52
+ const prev = currentScenario;
53
+ currentScenario = current;
54
+ return fn().finally(() => {
55
+ currentScenario = prev;
56
+ });
57
+ }
58
+ export const PREVIOUS = Symbol("previous measurement with that unit");
59
+ export function measureTime(name, options = {}) {
60
+ const end = Date.now();
61
+ reportMeasurement(name, end, "ms", { relativeTo: PREVIOUS, ...options });
62
+ }
63
+ export function reportMeasurement(name, value, unit, options = {}) {
64
+ if (!currentScenario) {
65
+ throw new Error("reportMeasurement() must be called inside of describe()");
66
+ }
67
+ let { relativeTo, scenario, props } = options;
68
+ if (relativeTo === PREVIOUS) {
69
+ relativeTo = "previous";
70
+ for (const [prevName, prev] of currentScenario.measurements) {
71
+ if (prev.unit === unit) {
72
+ relativeTo = prevName;
73
+ }
74
+ }
75
+ }
76
+ currentScenario.measurements.set(name, {
77
+ value,
78
+ unit,
79
+ });
80
+ let reportedValue = value;
81
+ if (relativeTo) {
82
+ const prev = currentScenario.measurements.get(relativeTo);
83
+ if (!prev) {
84
+ throw new Error(`No measurement named ${relativeTo} found`);
85
+ }
86
+ if (prev.unit !== "ms") {
87
+ throw new Error(`Measurement ${relativeTo} is not a "${unit}" measurement`);
88
+ }
89
+ reportedValue -= prev.value;
90
+ }
91
+ currentScenario.iface.measurement(scenario ?? currentScenario.scenario.scenario.name, props
92
+ ? {
93
+ ...currentScenario.scenario.props,
94
+ ...props,
95
+ }
96
+ : currentScenario.scenario.props, name, reportedValue, unit, relativeTo);
97
+ }
package/dist/file.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function waitForFile(path: string, timeout: number): Promise<void>;
package/dist/file.js ADDED
@@ -0,0 +1,54 @@
1
+ import { watch } from "fs";
2
+ import { access, constants } from "fs/promises";
3
+ import { dirname } from "path";
4
+ export async function waitForFile(path, timeout) {
5
+ let currentAction = "";
6
+ let timeoutRef;
7
+ const timeoutPromise = new Promise((resolve, reject) => {
8
+ timeoutRef = setTimeout(() => {
9
+ reject(new Error(`Timed out waiting for file ${path} (${currentAction}))`));
10
+ }, timeout || 60000);
11
+ });
12
+ const elements = [];
13
+ let current = path;
14
+ while (true) {
15
+ elements.push(current);
16
+ const parent = dirname(current);
17
+ if (parent === current) {
18
+ break;
19
+ }
20
+ current = parent;
21
+ }
22
+ elements.reverse();
23
+ try {
24
+ for (const path of elements) {
25
+ const checkAccess = () => access(path, constants.F_OK)
26
+ .then(() => true)
27
+ .catch(() => false);
28
+ if (!(await checkAccess())) {
29
+ let resolveCheckAgain = () => { };
30
+ const watcher = watch(dirname(path), () => {
31
+ resolveCheckAgain();
32
+ });
33
+ currentAction = `waiting for ${path}`;
34
+ let checkAgainPromise = new Promise((resolve) => {
35
+ resolveCheckAgain = resolve;
36
+ });
37
+ try {
38
+ do {
39
+ await Promise.race([timeoutPromise, checkAgainPromise]);
40
+ checkAgainPromise = new Promise((resolve) => {
41
+ resolveCheckAgain = resolve;
42
+ });
43
+ } while (!(await checkAccess()));
44
+ }
45
+ finally {
46
+ watcher.close();
47
+ }
48
+ }
49
+ }
50
+ }
51
+ finally {
52
+ clearTimeout(timeoutRef);
53
+ }
54
+ }
@@ -0,0 +1,34 @@
1
+ export type ConfigFor<P> = {
2
+ [K in keyof P]: P[K] extends string ? string[] : P[K] extends number ? number[] : P[K] extends boolean ? boolean[] | boolean : never;
3
+ };
4
+ export interface Scenario {
5
+ name: string;
6
+ config: Record<string, (string | number | boolean)[]>;
7
+ only: boolean;
8
+ fn: (props: Record<string, string | number | boolean>) => Promise<void>;
9
+ }
10
+ export interface ScenarioVariant {
11
+ scenario: Scenario;
12
+ props: Record<string, string | number | boolean>;
13
+ }
14
+ export interface CurrentScenario {
15
+ scenario: ScenarioVariant;
16
+ iface: FullInterface;
17
+ measurements: Map<string, {
18
+ value: number;
19
+ unit: string;
20
+ }>;
21
+ }
22
+ export type Interface = Partial<FullInterface>;
23
+ export interface FullInterface {
24
+ filterScenarios(scenarios: Scenario[]): Promise<Scenario[]>;
25
+ filterScenarioVariants(scenarioVariants: ScenarioVariant[]): Promise<ScenarioVariant[]>;
26
+ start(scenario: string, props: Record<string, string | number | boolean | null>): Promise<void>;
27
+ measurement(scenario: string, props: Record<string, string | number | boolean | null>, name: string, value: number, unit: string, relativeTo?: string): Promise<void>;
28
+ end(scenario: string, props: Record<string, string | number | boolean | null>): Promise<void>;
29
+ error(scenario: string, props: Record<string, string | number | boolean | null>, error: unknown): Promise<void>;
30
+ finish(): Promise<void>;
31
+ }
32
+ export declare function intoFullInterface(iface: Interface): FullInterface;
33
+ export { describe, measureTime, reportMeasurement, PREVIOUS, } from "./describe.js";
34
+ export { runScenarios } from "./runner.js";
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ export function intoFullInterface(iface) {
2
+ return {
3
+ filterScenarios: iface.filterScenarios ?? (async (scenarios) => scenarios),
4
+ filterScenarioVariants: iface.filterScenarioVariants ??
5
+ (async (scenarioVariants) => scenarioVariants),
6
+ start: iface.start ?? (async () => { }),
7
+ measurement: iface.measurement ?? (async () => { }),
8
+ end: iface.end ?? (async () => { }),
9
+ error: iface.error ?? (async () => { }),
10
+ finish: iface.finish ?? (async () => { }),
11
+ };
12
+ }
13
+ export { describe, measureTime, reportMeasurement, PREVIOUS, } from "./describe.js";
14
+ export { runScenarios } from "./runner.js";
@@ -0,0 +1,2 @@
1
+ import { Interface } from "../index.js";
2
+ export default function compose(...ifaces: Interface[]): Interface;
@@ -0,0 +1,33 @@
1
+ export default function compose(...ifaces) {
2
+ const allKeys = new Set();
3
+ for (const iface of ifaces) {
4
+ for (const key of Object.keys(iface)) {
5
+ allKeys.add(key);
6
+ }
7
+ }
8
+ const composed = {};
9
+ for (const key of allKeys) {
10
+ if (key.startsWith("filter")) {
11
+ composed[key] = async (items, ...args) => {
12
+ for (const iface of ifaces) {
13
+ const anyIface = iface;
14
+ if (anyIface[key]) {
15
+ items = await anyIface[key](items, ...args);
16
+ }
17
+ }
18
+ return items;
19
+ };
20
+ }
21
+ else {
22
+ composed[key] = async (...args) => {
23
+ for (const iface of ifaces) {
24
+ const anyIface = iface;
25
+ if (anyIface[key]) {
26
+ await anyIface[key](...args);
27
+ }
28
+ }
29
+ };
30
+ }
31
+ }
32
+ return composed;
33
+ }
@@ -0,0 +1,2 @@
1
+ import { Interface } from "../index.js";
2
+ export default function createInterface(): Interface;
@@ -0,0 +1,17 @@
1
+ import chalk from "chalk";
2
+ import { formatUnit } from "../units.js";
3
+ import { formatVariant } from "../utils.js";
4
+ export default function createInterface() {
5
+ const iface = {
6
+ start: async (scenario, props) => {
7
+ console.log(chalk.bold.underline(`Running ${formatVariant(scenario, props)}...`));
8
+ },
9
+ measurement: async (scenario, props, name, value, unit, relativeTo) => {
10
+ console.log(chalk.bgCyan.bold.magenta(`${formatVariant(scenario, props)}: ${name} = ${formatUnit(value, unit)}${relativeTo ? ` (from ${relativeTo})` : ""}`));
11
+ },
12
+ error: async (scenario, props, error) => {
13
+ console.log(chalk.bold.red(`${formatVariant(scenario, props)}: ${(error && error.stack) || error}`));
14
+ },
15
+ };
16
+ return iface;
17
+ }
@@ -0,0 +1,6 @@
1
+ import type { Interface } from "../index.js";
2
+ export default function createInterface({ apiKey, appKey, host, }?: {
3
+ apiKey?: string;
4
+ appKey?: string;
5
+ host?: string;
6
+ }): Interface;
@@ -0,0 +1,74 @@
1
+ import datadogApiClient from "@datadog/datadog-api-client";
2
+ import os from "os";
3
+ function toIdentifier(str) {
4
+ return str.replace(/\//g, ".").replace(/ /g, "_");
5
+ }
6
+ const UNIT_MAPPING = {
7
+ ms: "millisecond",
8
+ requests: "request",
9
+ bytes: "byte",
10
+ };
11
+ export default function createInterface({ apiKey = process.env.DATADOG_API_KEY, appKey = process.env.DATADOG_APP_KEY, host = process.env.DATADOG_HOST || os.hostname(), } = {}) {
12
+ if (!apiKey)
13
+ throw new Error("Datadog API key is required (set DATADOG_API_KEY)");
14
+ const commonTags = [
15
+ `ci:${!!process.env.CI || "false"}`,
16
+ `os:${process.platform}`,
17
+ `os_release:${os.release()}`,
18
+ `cpus:${os.cpus().length}`,
19
+ `cpu_model:${os.cpus()[0].model}`,
20
+ `user:${os.userInfo().username}`,
21
+ `arch:${os.arch()}`,
22
+ `total_memory:${Math.round(os.totalmem() / 1024 / 1024 / 1024)}`,
23
+ `node_version:${process.version}`,
24
+ ];
25
+ const configuration = datadogApiClient.client.createConfiguration({
26
+ authMethods: {
27
+ apiKeyAuth: apiKey,
28
+ appKeyAuth: appKey,
29
+ },
30
+ });
31
+ const api = new datadogApiClient.v1.MetricsApi(configuration);
32
+ const dataPoints = [];
33
+ const metricMetadata = {};
34
+ const iface = {
35
+ measurement: async (scenario, props, name, value, unit, relativeTo) => {
36
+ const ts = Math.round(Date.now() / 1000);
37
+ const metric = toIdentifier(`devlow_bench/${scenario}/${name}`);
38
+ if (UNIT_MAPPING[unit]) {
39
+ metricMetadata[metric] = {
40
+ unit: UNIT_MAPPING[unit],
41
+ };
42
+ }
43
+ dataPoints.push({
44
+ metric,
45
+ type: "distribution",
46
+ host,
47
+ tags: Object.entries(props)
48
+ .filter(([, value]) => value !== null)
49
+ .map(([key, value]) => `${toIdentifier(key)}:${toIdentifier(value.toString())}`)
50
+ .concat(commonTags),
51
+ points: [[ts, [value]]],
52
+ });
53
+ },
54
+ end: async (scenario, props) => {
55
+ await api.submitDistributionPoints({
56
+ body: {
57
+ series: dataPoints,
58
+ },
59
+ });
60
+ dataPoints.length = 0;
61
+ },
62
+ finish: async () => {
63
+ if (appKey) {
64
+ for (const [metric, metadata] of Object.entries(metricMetadata)) {
65
+ await api.updateMetricMetadata({
66
+ metricName: metric,
67
+ body: metadata,
68
+ });
69
+ }
70
+ }
71
+ },
72
+ };
73
+ return iface;
74
+ }
@@ -0,0 +1,2 @@
1
+ import { Interface } from "../index.js";
2
+ export default function createInterface(): Interface;
@@ -0,0 +1,41 @@
1
+ import inquirer from "inquirer";
2
+ import { formatVariant } from "../utils.js";
3
+ export default function createInterface() {
4
+ const iface = {
5
+ filterScenarios: async (scenarios) => {
6
+ if (scenarios.length === 1) {
7
+ return scenarios;
8
+ }
9
+ let answer = await inquirer.prompt({
10
+ type: "checkbox",
11
+ name: "scenarios",
12
+ default: scenarios.slice(),
13
+ message: "Choose scenarios to run",
14
+ choices: scenarios.map((scenario) => ({
15
+ name: scenario.name,
16
+ value: scenario,
17
+ })),
18
+ });
19
+ return answer.scenarios;
20
+ },
21
+ filterScenarioVariants: async (variants) => {
22
+ if (variants.length === 1) {
23
+ return variants;
24
+ }
25
+ let answer = await inquirer.prompt({
26
+ type: "checkbox",
27
+ name: "variants",
28
+ default: variants.slice(),
29
+ message: "Choose variants to run",
30
+ choices: variants.map((variant) => {
31
+ return {
32
+ name: formatVariant(variant.scenario.name, variant.props),
33
+ value: variant,
34
+ };
35
+ }),
36
+ });
37
+ return answer.variants;
38
+ },
39
+ };
40
+ return iface;
41
+ }
@@ -0,0 +1,2 @@
1
+ import { Interface } from "../index.js";
2
+ export default function createInterface(file?: string): Interface;
@@ -0,0 +1,57 @@
1
+ import { formatUnit } from "../units.js";
2
+ import { writeFile } from "fs/promises";
3
+ function filterProp(prop) {
4
+ const filteredProp = {};
5
+ for (const [key, value] of Object.entries(prop)) {
6
+ if (value !== null) {
7
+ filteredProp[key] = value;
8
+ }
9
+ }
10
+ return filteredProp;
11
+ }
12
+ export default function createInterface(file = (() => {
13
+ const file = process.env.JSON_OUTPUT_FILE;
14
+ if (!file) {
15
+ throw new Error("env var JSON_OUTPUT_FILE is not set");
16
+ }
17
+ return file;
18
+ })()) {
19
+ const metrics = new Map();
20
+ const iface = {
21
+ measurement: async (scenario, props, name, value, unit, relativeTo) => {
22
+ const keyObject = {
23
+ scenario: scenario,
24
+ ...filterProp(props),
25
+ name: name,
26
+ };
27
+ const key = JSON.stringify(keyObject);
28
+ const current = metrics.get(key);
29
+ if (current) {
30
+ current.value += value;
31
+ current.count++;
32
+ }
33
+ else {
34
+ metrics.set(key, {
35
+ key: keyObject,
36
+ value,
37
+ unit: unit,
38
+ count: 1,
39
+ relativeTo,
40
+ });
41
+ }
42
+ },
43
+ finish: async () => {
44
+ await writeFile(file, JSON.stringify([...metrics.values()].map(({ key, value, unit, count, relativeTo }) => {
45
+ return {
46
+ key,
47
+ value: value / count,
48
+ unit,
49
+ text: formatUnit(value / count, unit),
50
+ datapoints: count,
51
+ relativeTo,
52
+ };
53
+ })));
54
+ },
55
+ };
56
+ return iface;
57
+ }
@@ -0,0 +1,2 @@
1
+ import { Interface, Scenario } from "./index.js";
2
+ export declare function runScenarios(scenarios: Scenario[], iface: Interface): Promise<void>;
package/dist/runner.js ADDED
@@ -0,0 +1,54 @@
1
+ import { withCurrent } from "./describe.js";
2
+ import { intoFullInterface } from "./index.js";
3
+ export async function runScenarios(scenarios, iface) {
4
+ const fullIface = intoFullInterface(iface);
5
+ if (scenarios.some((scenario) => scenario.only)) {
6
+ scenarios = scenarios.filter((scenario) => scenario.only);
7
+ }
8
+ scenarios = await fullIface.filterScenarios(scenarios);
9
+ let variants = [];
10
+ for (const scenario of scenarios) {
11
+ let props = [{}];
12
+ for (const [key, options] of Object.entries(scenario.config)) {
13
+ const newProps = [];
14
+ for (const prop of props) {
15
+ if (prop === "scenario" || prop === "name")
16
+ throw new Error("Cannot use 'scenario' or 'name' as a property name");
17
+ for (const value of options) {
18
+ newProps.push({
19
+ ...prop,
20
+ [key]: value,
21
+ });
22
+ }
23
+ }
24
+ props = newProps;
25
+ }
26
+ variants.push(...props.map((props) => ({
27
+ scenario,
28
+ props,
29
+ })));
30
+ }
31
+ variants = await fullIface.filterScenarioVariants(variants);
32
+ for (const variant of variants) {
33
+ try {
34
+ const measurements = new Map();
35
+ await withCurrent({
36
+ iface: fullIface,
37
+ measurements,
38
+ scenario: variant,
39
+ }, async () => {
40
+ await fullIface.start(variant.scenario.name, variant.props);
41
+ measurements.set("start", {
42
+ value: Date.now(),
43
+ unit: "ms",
44
+ });
45
+ await variant.scenario.fn(variant.props);
46
+ await fullIface.end(variant.scenario.name, variant.props);
47
+ });
48
+ }
49
+ catch (e) {
50
+ await fullIface.error(variant.scenario.name, variant.props, e);
51
+ }
52
+ }
53
+ await fullIface.finish();
54
+ }
@@ -0,0 +1,19 @@
1
+ import { PREVIOUS } from "./describe.js";
2
+ export interface Command {
3
+ ok(): Promise<void>;
4
+ kill(): Promise<void>;
5
+ end(): Promise<number>;
6
+ waitForOutput(regex: RegExp): Promise<RegExpMatchArray>;
7
+ reportMemUsage(metricName: string, options: {
8
+ relativeTo?: string | typeof PREVIOUS;
9
+ scenario?: string;
10
+ props?: Record<string, string | number | null>;
11
+ }): Promise<void>;
12
+ stdout: string;
13
+ stderr: string;
14
+ output: string;
15
+ }
16
+ export declare function command(command: string, args: string[], options?: {
17
+ env?: Record<string, string>;
18
+ cwd?: string;
19
+ }): Command;
package/dist/shell.js ADDED
@@ -0,0 +1,111 @@
1
+ import { spawn } from "child_process";
2
+ import split2 from "split2";
3
+ import treeKill from "tree-kill";
4
+ import pidusage from "pidusage-tree";
5
+ import { reportMeasurement } from "./describe.js";
6
+ const shellOutput = !!process.env.SHELL_OUTPUT;
7
+ class CommandImpl {
8
+ constructor(process) {
9
+ this.process = process;
10
+ this.stdout = "";
11
+ this.stderr = "";
12
+ this.output = "";
13
+ this.waitingForOutput = [];
14
+ process.stdout?.pipe(split2()).on("data", (data) => {
15
+ const str = data.toString();
16
+ this.stdout += str + "\n";
17
+ this.output += str + "\n";
18
+ if (shellOutput) {
19
+ console.log(`[STDOUT] ${str}`);
20
+ }
21
+ if (this.waitingForOutput.length !== 0) {
22
+ const waitingForOutput = this.waitingForOutput;
23
+ this.waitingForOutput = [];
24
+ for (const fn of waitingForOutput) {
25
+ fn();
26
+ }
27
+ }
28
+ });
29
+ process.stderr?.pipe(split2()).on("data", (data) => {
30
+ const str = data.toString();
31
+ this.stderr += str + "\n";
32
+ this.output += str + "\n";
33
+ if (shellOutput) {
34
+ console.log(`[STDERR] ${str}`);
35
+ }
36
+ if (this.waitingForOutput.length !== 0) {
37
+ const waitingForOutput = this.waitingForOutput;
38
+ this.waitingForOutput = [];
39
+ for (const fn of waitingForOutput) {
40
+ fn();
41
+ }
42
+ }
43
+ });
44
+ this.exitPromise = new Promise((resolve, reject) => {
45
+ process.on("error", reject);
46
+ process.on("exit", resolve);
47
+ });
48
+ }
49
+ async ok() {
50
+ const exitCode = await this.exitPromise;
51
+ if (exitCode !== 0) {
52
+ throw new Error(`Command exited with code ${exitCode}\n\nOutput:\n${this.output}`);
53
+ }
54
+ }
55
+ async end() {
56
+ return await this.exitPromise;
57
+ }
58
+ async kill() {
59
+ const pid = this.process.pid;
60
+ await new Promise((resolve, reject) => treeKill(pid, (err) => {
61
+ if (err)
62
+ reject(err);
63
+ else
64
+ resolve();
65
+ }));
66
+ await this.exitPromise;
67
+ }
68
+ async waitForOutput(regex) {
69
+ let start = this.output.length;
70
+ while (true) {
71
+ const match = this.output.slice(start).match(regex);
72
+ if (match) {
73
+ return match;
74
+ }
75
+ const waitResult = await Promise.race([
76
+ this.exitPromise,
77
+ new Promise((resolve) => {
78
+ this.waitingForOutput.push(resolve);
79
+ }).then(() => "output"),
80
+ ]);
81
+ if (waitResult !== "output") {
82
+ throw new Error(`Command exited with code ${waitResult}\n\nOutput:\n${this.output}`);
83
+ }
84
+ }
85
+ }
86
+ async reportMemUsage(metricName, options = {}) {
87
+ try {
88
+ const pid = this.process.pid;
89
+ const report = await pidusage(pid);
90
+ const memUsage = Object.values(report)
91
+ .filter((x) => x)
92
+ .map((x) => x.memory)
93
+ .reduce((a, b) => a + b, 0);
94
+ reportMeasurement(metricName, memUsage, "bytes", options);
95
+ }
96
+ catch (e) {
97
+ // ignore
98
+ }
99
+ }
100
+ }
101
+ export function command(command, args, options = {}) {
102
+ const process = spawn(command, args, {
103
+ shell: true,
104
+ ...options,
105
+ stdio: ["ignore", "pipe", "pipe"],
106
+ });
107
+ if (shellOutput) {
108
+ console.log(`[SHELL] ${command} ${args.join(" ")} ${JSON.stringify(options)}`);
109
+ }
110
+ return new CommandImpl(process);
111
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/table.js ADDED
@@ -0,0 +1,117 @@
1
+ import { readFile } from "fs/promises";
2
+ import minimist from "minimist";
3
+ (async () => {
4
+ const args = minimist(process.argv.slice(2), {
5
+ alias: {
6
+ r: "row",
7
+ c: "column",
8
+ "?": "help",
9
+ h: "help",
10
+ },
11
+ });
12
+ const knownArgs = new Set(["row", "r", "column", "c", "help", "h", "?", "_"]);
13
+ if (args.help || (Object.keys(args).length === 1 && args._.length === 0)) {
14
+ console.log("Usage: devlow-table <data.json>");
15
+ console.log(" --row=<name> Key to show as row");
16
+ console.log(" --column=<name> Key to show as column");
17
+ console.log(" --<prop>=<name> Filter values");
18
+ console.log(" --help, -h, -? Show this help");
19
+ }
20
+ let data = JSON.parse(await readFile(args._[0], "utf-8"));
21
+ const getValue = (data, name, includeKey) => {
22
+ if (name === "value") {
23
+ return data.text;
24
+ }
25
+ if (Array.isArray(name)) {
26
+ return name
27
+ .map((n) => getValue(data, n, true))
28
+ .filter((x) => x)
29
+ .join(" ");
30
+ }
31
+ const value = data.key[name];
32
+ if (value === undefined)
33
+ return "";
34
+ if (value === true)
35
+ return includeKey ? name : "true";
36
+ if (value === false)
37
+ return includeKey ? "" : "false";
38
+ if (value === null)
39
+ return "";
40
+ if (includeKey)
41
+ return `${name}=${value}`;
42
+ return value + "";
43
+ };
44
+ for (const [key, value] of Object.entries(args)) {
45
+ if (knownArgs.has(key))
46
+ continue;
47
+ const values = (Array.isArray(value) ? value : [value]).map((v) => v.toString());
48
+ data = data.filter((item) => {
49
+ const itemValue = getValue(item, key, false);
50
+ if (itemValue === "")
51
+ return false;
52
+ return values.some((v) => itemValue === v);
53
+ });
54
+ }
55
+ if (data.length === 0) {
56
+ console.log("No data");
57
+ return;
58
+ }
59
+ const row = args.row || "name";
60
+ const column = args.column || "scenario";
61
+ const getRow = (data) => getValue(data, row, false);
62
+ const getColumn = (data) => getValue(data, column, false);
63
+ const allRows = new Set(data.map(getRow));
64
+ const allColumns = new Set(data.map(getColumn));
65
+ const table = [];
66
+ const columnSizes = [...allColumns].map((c) => c.length);
67
+ for (const row of allRows) {
68
+ const rowData = [];
69
+ let i = 0;
70
+ for (const column of allColumns) {
71
+ let items = data
72
+ .filter((d) => getRow(d) === row && getColumn(d) === column)
73
+ .map((i) => i.text);
74
+ rowData.push(items.join(", "));
75
+ columnSizes[i] = Math.max(columnSizes[i], rowData[i].length);
76
+ i++;
77
+ }
78
+ table.push(rowData);
79
+ }
80
+ const pad = (str, size) => {
81
+ return " ".repeat(size - str.length) + str;
82
+ };
83
+ const firstColumnSize = Math.max(...[...allRows].map((r) => r.length));
84
+ // Header
85
+ {
86
+ let row = "| ";
87
+ let sepRow = "|:";
88
+ row += " ".repeat(firstColumnSize);
89
+ sepRow += "-".repeat(firstColumnSize);
90
+ const allColumnsArray = [...allColumns];
91
+ for (let i = 0; i < columnSizes.length; i++) {
92
+ row += " | ";
93
+ row += pad(allColumnsArray[i], columnSizes[i]);
94
+ sepRow += ":|-";
95
+ sepRow += "-".repeat(columnSizes[i]);
96
+ }
97
+ row += " |";
98
+ sepRow += ":|";
99
+ console.log(row);
100
+ console.log(sepRow);
101
+ }
102
+ // Separator
103
+ let r = 0;
104
+ for (const rowName of allRows) {
105
+ let row = "| ";
106
+ row += pad(rowName, firstColumnSize);
107
+ for (let i = 0; i < columnSizes.length; i++) {
108
+ row += " | ";
109
+ row += pad(table[r][i], columnSizes[i]);
110
+ }
111
+ row += " |";
112
+ console.log(row);
113
+ r++;
114
+ }
115
+ })().catch((e) => {
116
+ console.error(e.stack);
117
+ });
@@ -0,0 +1 @@
1
+ export declare function formatUnit(value: number, unit: string): string;
package/dist/units.js ADDED
@@ -0,0 +1,24 @@
1
+ const UNITS = {
2
+ ms: {
3
+ " s": 1000,
4
+ },
5
+ bytes: {
6
+ " GB": 1024 * 1024 * 1024,
7
+ " MB": 1024 * 1024,
8
+ " KB": 1024,
9
+ },
10
+ requests: {
11
+ "K requests": 1000,
12
+ },
13
+ };
14
+ export function formatUnit(value, unit) {
15
+ const conversion = UNITS[unit];
16
+ if (conversion) {
17
+ for (const [name, factor] of Object.entries(conversion)) {
18
+ if (value >= factor) {
19
+ return `${(value / factor).toFixed(2)}${name}`;
20
+ }
21
+ }
22
+ }
23
+ return `${value.toFixed(2).replace(/\.00$/, "")} ${unit}`;
24
+ }
@@ -0,0 +1 @@
1
+ export declare function formatVariant(scenario: string, props: Record<string, string | number | boolean | null>): string;
package/dist/utils.js ADDED
@@ -0,0 +1,9 @@
1
+ export function formatVariant(scenario, props) {
2
+ const keys = Object.keys(props)
3
+ .filter((key) => props[key] !== false && props[key] !== null)
4
+ .map((key) => (props[key] === true ? key : `${key}=${props[key]}`));
5
+ if (keys.length === 0) {
6
+ return scenario;
7
+ }
8
+ return `${scenario} ${keys.join(" ")}`;
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vercel/devlow-bench",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Benchmarking tool for the developer workflow",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",