@vercel/devlow-bench 0.3.4 → 16.4.0-canary.24

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.
Files changed (46) hide show
  1. package/README.md +50 -33
  2. package/dist/browser.d.ts +1 -1
  3. package/dist/browser.js +52 -44
  4. package/dist/cli.js +114 -69
  5. package/dist/compare.d.ts +14 -0
  6. package/dist/compare.js +209 -0
  7. package/dist/describe.d.ts +1 -1
  8. package/dist/describe.js +18 -18
  9. package/dist/file.js +5 -5
  10. package/dist/index.d.ts +16 -3
  11. package/dist/index.js +3 -2
  12. package/dist/interfaces/compare.d.ts +4 -0
  13. package/dist/interfaces/compare.js +27 -0
  14. package/dist/interfaces/compose.d.ts +1 -1
  15. package/dist/interfaces/compose.js +1 -1
  16. package/dist/interfaces/console.d.ts +4 -2
  17. package/dist/interfaces/console.js +30 -8
  18. package/dist/interfaces/constants.js +7 -7
  19. package/dist/interfaces/datadog.d.ts +1 -1
  20. package/dist/interfaces/datadog.js +13 -13
  21. package/dist/interfaces/interactive.d.ts +1 -1
  22. package/dist/interfaces/interactive.js +8 -8
  23. package/dist/interfaces/json.d.ts +4 -2
  24. package/dist/interfaces/json.js +36 -17
  25. package/dist/interfaces/shell-test.d.ts +1 -0
  26. package/dist/interfaces/shell-test.js +56 -0
  27. package/dist/interfaces/snapshot.d.ts +6 -0
  28. package/dist/interfaces/snapshot.js +60 -0
  29. package/dist/interfaces/snowflake-test.js +3 -3
  30. package/dist/interfaces/snowflake.d.ts +1 -1
  31. package/dist/interfaces/snowflake.js +15 -13
  32. package/dist/runner.d.ts +5 -2
  33. package/dist/runner.js +132 -24
  34. package/dist/shell.d.ts +5 -3
  35. package/dist/shell.js +56 -28
  36. package/dist/snapshot.d.ts +18 -0
  37. package/dist/snapshot.js +162 -0
  38. package/dist/statistics-test.d.ts +1 -0
  39. package/dist/statistics-test.js +102 -0
  40. package/dist/statistics.d.ts +19 -0
  41. package/dist/statistics.js +123 -0
  42. package/dist/table.js +51 -51
  43. package/dist/units.js +6 -6
  44. package/dist/utils.d.ts +1 -0
  45. package/dist/utils.js +8 -6
  46. package/package.json +6 -5
@@ -1,5 +1,6 @@
1
- import { formatUnit } from "../units.js";
2
- import { writeFile } from "fs/promises";
1
+ import { quantile, mean as statsMean } from '../statistics.js';
2
+ import { formatUnit } from '../units.js';
3
+ import { writeFile } from 'fs/promises';
3
4
  function filterProp(prop) {
4
5
  const filteredProp = {};
5
6
  for (const [key, value] of Object.entries(prop)) {
@@ -10,12 +11,14 @@ function filterProp(prop) {
10
11
  return filteredProp;
11
12
  }
12
13
  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");
14
+ const fromEnv = process.env.JSON_OUTPUT_FILE;
15
+ if (!fromEnv) {
16
+ throw new Error('env var JSON_OUTPUT_FILE is not set');
16
17
  }
17
- return file;
18
- })()) {
18
+ return fromEnv;
19
+ })(), options = {}) {
20
+ const n = options.n ?? 1;
21
+ // Per-(scenario, props, name) sample accumulator.
19
22
  const metrics = new Map();
20
23
  const iface = {
21
24
  measurement: async (scenario, props, name, value, unit, relativeTo) => {
@@ -27,30 +30,46 @@ export default function createInterface(file = (() => {
27
30
  const key = JSON.stringify(keyObject);
28
31
  const current = metrics.get(key);
29
32
  if (current) {
30
- current.value += value;
31
- current.count++;
33
+ current.samples.push(value);
32
34
  }
33
35
  else {
34
36
  metrics.set(key, {
35
37
  key: keyObject,
36
- value,
37
- unit: unit,
38
- count: 1,
38
+ samples: [value],
39
+ unit,
39
40
  relativeTo,
40
41
  });
41
42
  }
42
43
  },
43
44
  finish: async () => {
44
- await writeFile(file, JSON.stringify([...metrics.values()].map(({ key, value, unit, count, relativeTo }) => {
45
+ const results = [...metrics.values()].map(({ key, samples, unit, relativeTo }) => {
46
+ if (samples.length === 1) {
47
+ // Preserve the original single-run shape exactly for back-compat.
48
+ return {
49
+ key,
50
+ value: samples[0],
51
+ unit,
52
+ text: formatUnit(samples[0], unit),
53
+ datapoints: 1,
54
+ relativeTo,
55
+ };
56
+ }
57
+ const m = statsMean(samples);
45
58
  return {
46
59
  key,
47
- value: value / count,
60
+ value: m,
48
61
  unit,
49
- text: formatUnit(value / count, unit),
50
- datapoints: count,
62
+ text: formatUnit(m, unit),
63
+ datapoints: samples.length,
64
+ mean: m,
65
+ p50: quantile(samples, 0.5),
66
+ p90: quantile(samples, 0.9),
67
+ samples,
51
68
  relativeTo,
52
69
  };
53
- })));
70
+ });
71
+ const payload = n > 1 ? { results } : results;
72
+ await writeFile(file, JSON.stringify(payload));
54
73
  },
55
74
  };
56
75
  return iface;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,56 @@
1
+ import assert from 'assert';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { test } from 'node:test';
6
+ import { command } from '../shell.js';
7
+ async function withTempScript(scriptContents, fn) {
8
+ const dir = await mkdtemp(join(tmpdir(), 'devlow-shell-test-'));
9
+ const scriptPath = join(dir, 'script.js');
10
+ try {
11
+ await writeFile(scriptPath, scriptContents, 'utf8');
12
+ await fn(scriptPath);
13
+ }
14
+ finally {
15
+ await rm(dir, { recursive: true, force: true });
16
+ }
17
+ }
18
+ test('waitForOutput handles sequential waits after buffered output', async () => {
19
+ await withTempScript(`
20
+ console.log('FIRST_MARKER')
21
+ console.log('SECOND_MARKER')
22
+ setTimeout(() => {}, 1000)
23
+ `, async (scriptPath) => {
24
+ const shell = command('node', [scriptPath]);
25
+ try {
26
+ const first = await shell.waitForOutput(/FIRST_MARKER\n/, {
27
+ timeoutMs: 2_000,
28
+ });
29
+ assert.equal(first[0], 'FIRST_MARKER\n');
30
+ const second = await shell.waitForOutput(/SECOND_MARKER\n/, {
31
+ timeoutMs: 2_000,
32
+ });
33
+ assert.equal(second[0], 'SECOND_MARKER\n');
34
+ }
35
+ finally {
36
+ await shell.kill();
37
+ }
38
+ });
39
+ });
40
+ test('waitForOutput times out when output never appears', async () => {
41
+ await withTempScript(`
42
+ console.log('FIRST_MARKER')
43
+ setTimeout(() => {}, 1000)
44
+ `, async (scriptPath) => {
45
+ const shell = command('node', [scriptPath]);
46
+ try {
47
+ await shell.waitForOutput(/FIRST_MARKER\n/, {
48
+ timeoutMs: 2_000,
49
+ });
50
+ await assert.rejects(shell.waitForOutput(/NEVER_SEEN\n/, { timeoutMs: 100 }), /Timed out waiting for output matching/);
51
+ }
52
+ finally {
53
+ await shell.kill();
54
+ }
55
+ });
56
+ });
@@ -0,0 +1,6 @@
1
+ import type { Interface } from '../index.js';
2
+ export default function createInterface(options?: {
3
+ path?: string;
4
+ }): Interface & {
5
+ resolvedPath: string;
6
+ };
@@ -0,0 +1,60 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import picocolors from 'picocolors';
4
+ import { defaultSnapshotPath, writeSnapshot, } from '../snapshot.js';
5
+ import { formatVariantProps } from '../utils.js';
6
+ const execFileAsync = promisify(execFile);
7
+ async function readGitInfo() {
8
+ const sha = process.env.GITHUB_SHA || (await tryGit(['rev-parse', 'HEAD']));
9
+ const branch = process.env.GITHUB_REF_NAME ||
10
+ (await tryGit(['rev-parse', '--abbrev-ref', 'HEAD']));
11
+ return { sha, branch };
12
+ }
13
+ async function tryGit(args) {
14
+ try {
15
+ const { stdout } = await execFileAsync('git', args);
16
+ return stdout.trim();
17
+ }
18
+ catch {
19
+ return '';
20
+ }
21
+ }
22
+ export default function createInterface(options = {}) {
23
+ const path = options.path ?? defaultSnapshotPath();
24
+ const rows = [];
25
+ const timestamp = new Date().toISOString();
26
+ const iface = {
27
+ resolvedPath: path,
28
+ variantStatistics: async (scenario, props, stats) => {
29
+ const variant = formatVariantProps(props);
30
+ for (const [metric, s] of Object.entries(stats)) {
31
+ for (let i = 0; i < s.samples.length; i++) {
32
+ rows.push({
33
+ timestamp,
34
+ scenario,
35
+ variant,
36
+ metric,
37
+ sample_idx: i + 1,
38
+ value: s.samples[i],
39
+ unit: s.unit,
40
+ relative_to: s.relativeTo ?? '',
41
+ git_sha: '',
42
+ git_branch: '',
43
+ });
44
+ }
45
+ }
46
+ },
47
+ finish: async () => {
48
+ if (rows.length === 0)
49
+ return;
50
+ const { sha, branch } = await readGitInfo();
51
+ for (const r of rows) {
52
+ r.git_sha = sha;
53
+ r.git_branch = branch;
54
+ }
55
+ await writeSnapshot(path, rows);
56
+ console.log(picocolors.dim(`Wrote ${rows.length} rows to ${path}`));
57
+ },
58
+ };
59
+ return iface;
60
+ }
@@ -1,6 +1,6 @@
1
- import assert from "assert";
2
- import createInterface from "./snowflake.js";
3
- import { mock, test } from "node:test";
1
+ import assert from 'assert';
2
+ import createInterface from './snowflake.js';
3
+ import { mock, test } from 'node:test';
4
4
  test('sending measurements to the batch endpoint', async () => {
5
5
  const requests = [];
6
6
  const fetchMock = mock.method(global, 'fetch', (req, options) => {
@@ -1,4 +1,4 @@
1
- import type { Interface } from "../index.js";
1
+ import type { Interface } from '../index.js';
2
2
  export default function createInterface({ gatewayUri, topicName, schemaId, }?: {
3
3
  gatewayUri?: string;
4
4
  topicName?: string;
@@ -1,13 +1,15 @@
1
- import os from "os";
2
- import { CPU_ARCH, CPU_MODEL, GIT_BRANCH, GIT_SHA, IS_CI, NODE_VERSION, NUM_CPUS, OS, OS_RELEASE, USERNAME } from "./constants.js";
3
- import { randomUUID } from "crypto";
4
- export default function createInterface({ gatewayUri = process.env.SNOWFLAKE_BATCH_URI, topicName = process.env.SNOWFLAKE_TOPIC_NAME, schemaId = process.env.SNOWFLAKE_SCHEMA_ID ? parseInt(process.env.SNOWFLAKE_SCHEMA_ID, 10) : undefined, } = {}) {
1
+ import os from 'os';
2
+ import { CPU_ARCH, CPU_MODEL, GIT_BRANCH, GIT_SHA, IS_CI, NODE_VERSION, NUM_CPUS, OS, OS_RELEASE, USERNAME, } from './constants.js';
3
+ import { randomUUID } from 'crypto';
4
+ export default function createInterface({ gatewayUri = process.env.SNOWFLAKE_BATCH_URI, topicName = process.env.SNOWFLAKE_TOPIC_NAME, schemaId = process.env.SNOWFLAKE_SCHEMA_ID
5
+ ? parseInt(process.env.SNOWFLAKE_SCHEMA_ID, 10)
6
+ : undefined, } = {}) {
5
7
  if (!gatewayUri)
6
- throw new Error("Snowflake gateway URI is required (set SNOWFLAKE_GATEWAY_URI)");
8
+ throw new Error('Snowflake gateway URI is required (set SNOWFLAKE_GATEWAY_URI)');
7
9
  if (!topicName)
8
- throw new Error("Snowflake topic name is required (set SNOWFLAKE_TOPIC_NAME)");
10
+ throw new Error('Snowflake topic name is required (set SNOWFLAKE_TOPIC_NAME)');
9
11
  if (!schemaId)
10
- throw new Error("Snowflake schema ID is required (set SNOWFLAKE_SCHEMA_ID to a valid integer)");
12
+ throw new Error('Snowflake schema ID is required (set SNOWFLAKE_SCHEMA_ID to a valid integer)');
11
13
  const records = [];
12
14
  const iface = {
13
15
  measurement: async (scenario, props, name, value, unit, relativeTo) => {
@@ -32,7 +34,7 @@ export default function createInterface({ gatewayUri = process.env.SNOWFLAKE_BAT
32
34
  git_branch: GIT_BRANCH,
33
35
  });
34
36
  },
35
- end: async (scenario, props) => {
37
+ end: async (_scenario, _props) => {
36
38
  await trackAnalytics(gatewayUri, topicName, schemaId, records);
37
39
  },
38
40
  };
@@ -41,10 +43,10 @@ export default function createInterface({ gatewayUri = process.env.SNOWFLAKE_BAT
41
43
  async function trackAnalytics(batchUri, topic, schemaId, records) {
42
44
  try {
43
45
  const res = await fetch(batchUri, {
44
- method: "POST",
46
+ method: 'POST',
45
47
  headers: {
46
- "Client-Id": "nextjs",
47
- "Content-Type": "application/json",
48
+ 'Client-Id': 'nextjs',
49
+ 'Content-Type': 'application/json',
48
50
  },
49
51
  body: JSON.stringify({
50
52
  schema_id: schemaId,
@@ -53,7 +55,7 @@ async function trackAnalytics(batchUri, topic, schemaId, records) {
53
55
  ...record,
54
56
  id: randomUUID(),
55
57
  props_json: JSON.stringify(record.props),
56
- }, ["props"])),
58
+ }, ['props'])),
57
59
  }),
58
60
  });
59
61
  if (!res.ok) {
@@ -61,7 +63,7 @@ async function trackAnalytics(batchUri, topic, schemaId, records) {
61
63
  }
62
64
  }
63
65
  catch (e) {
64
- const wrappedError = new Error("Unexpected error tracking analytics event");
66
+ const wrappedError = new Error('Unexpected error tracking analytics event');
65
67
  wrappedError.cause = e;
66
68
  console.error(wrappedError);
67
69
  }
package/dist/runner.d.ts CHANGED
@@ -1,2 +1,5 @@
1
- import { Interface, Scenario } from "./index.js";
2
- export declare function runScenarios(scenarios: Scenario[], iface: Interface): Promise<void>;
1
+ import type { Interface, Scenario } from './index.js';
2
+ export declare function runScenarios(scenarios: Scenario[], iface: Interface, options?: {
3
+ n?: number;
4
+ warmup?: number;
5
+ }): Promise<void>;
package/dist/runner.js CHANGED
@@ -1,20 +1,31 @@
1
- import { withCurrent } from "./describe.js";
2
- import { intoFullInterface } from "./index.js";
3
- export async function runScenarios(scenarios, iface) {
1
+ import { withCurrent } from './describe.js';
2
+ import { intoFullInterface } from './index.js';
3
+ import { summary } from './statistics.js';
4
+ import { formatVariant } from './utils.js';
5
+ // Total attempts per variant are capped at this multiple of the requested
6
+ // `warmup + n`. Allows retrying flaky runs without burning unbounded time when
7
+ // every attempt fails.
8
+ const MAX_ATTEMPT_MULTIPLIER = 2;
9
+ export async function runScenarios(scenarios, iface, options = {}) {
10
+ const n = Math.max(1, Math.floor(options.n ?? 1));
11
+ const warmup = Math.max(0, Math.floor(options.warmup ?? 0));
4
12
  const fullIface = intoFullInterface(iface);
5
13
  if (scenarios.some((scenario) => scenario.only)) {
6
14
  scenarios = scenarios.filter((scenario) => scenario.only);
7
15
  }
8
16
  scenarios = await fullIface.filterScenarios(scenarios);
17
+ let totalFailedAttempts = 0;
18
+ let variantsWithRetries = 0;
19
+ let variantsShortOfTarget = 0;
9
20
  let variants = [];
10
21
  for (const scenario of scenarios) {
11
22
  let props = [{}];
12
- for (const [key, options] of Object.entries(scenario.config)) {
23
+ for (const [key, values] of Object.entries(scenario.config)) {
13
24
  const newProps = [];
14
25
  for (const prop of props) {
15
- if (prop === "scenario" || prop === "name")
26
+ if (prop === 'scenario' || prop === 'name')
16
27
  throw new Error("Cannot use 'scenario' or 'name' as a property name");
17
- for (const value of options) {
28
+ for (const value of values) {
18
29
  newProps.push({
19
30
  ...prop,
20
31
  [key]: value,
@@ -23,32 +34,129 @@ export async function runScenarios(scenarios, iface) {
23
34
  }
24
35
  props = newProps;
25
36
  }
26
- variants.push(...props.map((props) => ({
37
+ variants.push(...props.map((variantProps) => ({
27
38
  scenario,
28
- props,
39
+ props: variantProps,
29
40
  })));
30
41
  }
31
42
  variants = await fullIface.filterScenarioVariants(variants);
32
43
  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
+ const samplesByMetric = new Map();
45
+ // Each attempt buffers its measurements here. On success we merge into
46
+ // `samplesByMetric`; on failure we discard, so a partial run can't
47
+ // pollute the aggregated stats. Downstream per-sample reporters (Datadog,
48
+ // console, …) still receive every measurement inline — only the
49
+ // aggregated per-variant samples are gated on success.
50
+ let collecting = false;
51
+ let perRun = new Map();
52
+ const wrappedIface = {
53
+ ...fullIface,
54
+ measurement: async (scenario, props, name, value, unit, relativeTo) => {
55
+ if (collecting) {
56
+ let list = perRun.get(name);
57
+ if (!list) {
58
+ list = [];
59
+ perRun.set(name, list);
60
+ }
61
+ list.push({ value, unit, relativeTo });
62
+ }
63
+ await fullIface.measurement(scenario, props, name, value, unit, relativeTo);
64
+ },
65
+ };
66
+ const maxAttempts = (warmup + n) * MAX_ATTEMPT_MULTIPLIER;
67
+ const showRunInfo = warmup + n > 1;
68
+ let warmupDone = 0;
69
+ let collectedRuns = 0;
70
+ let totalAttempts = 0;
71
+ while ((warmupDone < warmup || collectedRuns < n) &&
72
+ totalAttempts < maxAttempts) {
73
+ totalAttempts++;
74
+ const isWarmup = warmupDone < warmup;
75
+ collecting = !isWarmup;
76
+ perRun = new Map();
77
+ const runInfo = showRunInfo
78
+ ? isWarmup
79
+ ? { run: warmupDone + 1, total: warmup, warmup: true }
80
+ : { run: collectedRuns + 1, total: n, warmup: false }
81
+ : undefined;
82
+ try {
83
+ const measurements = new Map();
84
+ await withCurrent({
85
+ iface: wrappedIface,
86
+ measurements,
87
+ scenario: variant,
88
+ }, async () => {
89
+ await wrappedIface.start(variant.scenario.name, variant.props, runInfo);
90
+ measurements.set('start', {
91
+ value: Date.now(),
92
+ unit: 'ms',
93
+ });
94
+ await variant.scenario.fn(variant.props);
95
+ await wrappedIface.end(variant.scenario.name, variant.props);
44
96
  });
45
- await variant.scenario.fn(variant.props);
46
- await fullIface.end(variant.scenario.name, variant.props);
47
- });
97
+ if (collecting) {
98
+ // Commit the buffered samples for this run into the aggregate.
99
+ for (const [name, entries] of perRun) {
100
+ let set = samplesByMetric.get(name);
101
+ if (!set) {
102
+ const first = entries[0];
103
+ set = {
104
+ samples: [],
105
+ unit: first.unit,
106
+ relativeTo: first.relativeTo,
107
+ };
108
+ samplesByMetric.set(name, set);
109
+ }
110
+ for (const e of entries) {
111
+ set.samples.push(e.value);
112
+ }
113
+ }
114
+ collectedRuns++;
115
+ }
116
+ else {
117
+ warmupDone++;
118
+ }
119
+ }
120
+ catch (e) {
121
+ await wrappedIface.error(variant.scenario.name, variant.props, e);
122
+ // Loop continues; the buffered samples for this attempt are dropped
123
+ // when `perRun` is replaced on the next iteration.
124
+ }
125
+ }
126
+ const failedAttempts = totalAttempts - warmupDone - collectedRuns;
127
+ if (failedAttempts > 0) {
128
+ const label = formatVariant(variant.scenario.name, variant.props);
129
+ console.log(`${label}: collected ${collectedRuns}/${n} samples after ${totalAttempts} attempts (${failedAttempts} failed)`);
130
+ totalFailedAttempts += failedAttempts;
131
+ variantsWithRetries++;
132
+ }
133
+ if (collectedRuns < n) {
134
+ process.exitCode = 1;
135
+ variantsShortOfTarget++;
136
+ }
137
+ if (samplesByMetric.size > 0) {
138
+ const stats = {};
139
+ for (const [name, set] of samplesByMetric) {
140
+ stats[name] = {
141
+ samples: set.samples,
142
+ unit: set.unit,
143
+ relativeTo: set.relativeTo,
144
+ ...summary(set.samples),
145
+ };
146
+ }
147
+ await fullIface.variantStatistics(variant.scenario.name, variant.props, stats);
148
+ }
149
+ }
150
+ if (totalFailedAttempts > 0) {
151
+ const recovered = variantsWithRetries - variantsShortOfTarget;
152
+ const parts = [];
153
+ if (recovered > 0) {
154
+ parts.push(`${recovered} variant${recovered === 1 ? '' : 's'} hit n=${n} after retries`);
48
155
  }
49
- catch (e) {
50
- await fullIface.error(variant.scenario.name, variant.props, e);
156
+ if (variantsShortOfTarget > 0) {
157
+ parts.push(`${variantsShortOfTarget} variant${variantsShortOfTarget === 1 ? '' : 's'} fell short of n=${n}`);
51
158
  }
159
+ console.log(`\n${totalFailedAttempts} failed attempt${totalFailedAttempts === 1 ? '' : 's'} across ${variantsWithRetries} variant${variantsWithRetries === 1 ? '' : 's'}: ${parts.join('; ')}.`);
52
160
  }
53
161
  await fullIface.finish();
54
162
  }
package/dist/shell.d.ts CHANGED
@@ -1,9 +1,11 @@
1
- import { PREVIOUS } from "./describe.js";
1
+ import type { PREVIOUS } from './describe.js';
2
2
  export interface Command {
3
3
  ok(): Promise<void>;
4
4
  kill(): Promise<void>;
5
5
  end(): Promise<number>;
6
- waitForOutput(regex: RegExp): Promise<RegExpMatchArray>;
6
+ waitForOutput(regex: RegExp, options?: {
7
+ timeoutMs?: number;
8
+ }): Promise<RegExpMatchArray>;
7
9
  reportMemUsage(metricName: string, options: {
8
10
  relativeTo?: string | typeof PREVIOUS;
9
11
  scenario?: string;
@@ -13,7 +15,7 @@ export interface Command {
13
15
  stderr: string;
14
16
  output: string;
15
17
  }
16
- export declare function command(command: string, args: string[], options?: {
18
+ export declare function command(executable: string, args: string[], options?: {
17
19
  env?: Record<string, string>;
18
20
  cwd?: string;
19
21
  }): Command;
package/dist/shell.js CHANGED
@@ -1,22 +1,23 @@
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";
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
6
  const shellOutput = !!process.env.SHELL_OUTPUT;
7
7
  class CommandImpl {
8
8
  process;
9
- stdout = "";
10
- stderr = "";
11
- output = "";
9
+ stdout = '';
10
+ stderr = '';
11
+ output = '';
12
+ outputCursor = 0;
12
13
  exitPromise;
13
14
  waitingForOutput = [];
14
15
  constructor(process) {
15
16
  this.process = process;
16
- process.stdout?.pipe(split2()).on("data", (data) => {
17
+ process.stdout?.pipe(split2()).on('data', (data) => {
17
18
  const str = data.toString();
18
- this.stdout += str + "\n";
19
- this.output += str + "\n";
19
+ this.stdout += str + '\n';
20
+ this.output += str + '\n';
20
21
  if (shellOutput) {
21
22
  console.log(`[STDOUT] ${str}`);
22
23
  }
@@ -28,10 +29,10 @@ class CommandImpl {
28
29
  }
29
30
  }
30
31
  });
31
- process.stderr?.pipe(split2()).on("data", (data) => {
32
+ process.stderr?.pipe(split2()).on('data', (data) => {
32
33
  const str = data.toString();
33
- this.stderr += str + "\n";
34
- this.output += str + "\n";
34
+ this.stderr += str + '\n';
35
+ this.output += str + '\n';
35
36
  if (shellOutput) {
36
37
  console.log(`[STDERR] ${str}`);
37
38
  }
@@ -44,8 +45,8 @@ class CommandImpl {
44
45
  }
45
46
  });
46
47
  this.exitPromise = new Promise((resolve, reject) => {
47
- process.on("error", reject);
48
- process.on("exit", resolve);
48
+ process.on('error', reject);
49
+ process.on('exit', resolve);
49
50
  });
50
51
  }
51
52
  async ok() {
@@ -67,20 +68,47 @@ class CommandImpl {
67
68
  }));
68
69
  await this.exitPromise;
69
70
  }
70
- async waitForOutput(regex) {
71
- let start = this.output.length;
71
+ async waitForOutput(regex, options = {}) {
72
+ let start = this.outputCursor;
73
+ const deadline = options.timeoutMs === undefined
74
+ ? undefined
75
+ : Date.now() + options.timeoutMs;
72
76
  while (true) {
73
- const match = this.output.slice(start).match(regex);
77
+ const outputToSearch = this.output.slice(start);
78
+ const match = outputToSearch.match(regex);
74
79
  if (match) {
80
+ const matchIndex = match.index ?? outputToSearch.search(regex);
81
+ if (matchIndex >= 0) {
82
+ this.outputCursor = start + matchIndex + match[0].length;
83
+ }
84
+ else {
85
+ this.outputCursor = this.output.length;
86
+ }
75
87
  return match;
76
88
  }
77
- const waitResult = await Promise.race([
89
+ const promises = [
78
90
  this.exitPromise,
79
91
  new Promise((resolve) => {
80
92
  this.waitingForOutput.push(resolve);
81
- }).then(() => "output"),
82
- ]);
83
- if (waitResult !== "output") {
93
+ }).then(() => 'output'),
94
+ ];
95
+ let timeoutId;
96
+ if (deadline !== undefined) {
97
+ const timeoutMs = deadline - Date.now();
98
+ if (timeoutMs <= 0) {
99
+ throw new Error(`Timed out waiting for output matching ${regex}\n\nOutput:\n${this.output}`);
100
+ }
101
+ promises.push(new Promise((resolve) => {
102
+ timeoutId = setTimeout(() => resolve('timeout'), timeoutMs);
103
+ }));
104
+ }
105
+ const waitResult = await Promise.race(promises);
106
+ if (timeoutId)
107
+ clearTimeout(timeoutId);
108
+ if (waitResult !== 'output') {
109
+ if (waitResult === 'timeout') {
110
+ throw new Error(`Timed out waiting for output matching ${regex}\n\nOutput:\n${this.output}`);
111
+ }
84
112
  throw new Error(`Command exited with code ${waitResult}\n\nOutput:\n${this.output}`);
85
113
  }
86
114
  }
@@ -93,21 +121,21 @@ class CommandImpl {
93
121
  .filter((x) => x)
94
122
  .map((x) => x.memory)
95
123
  .reduce((a, b) => a + b, 0);
96
- await reportMeasurement(metricName, memUsage, "bytes", options);
124
+ await reportMeasurement(metricName, memUsage, 'bytes', options);
97
125
  }
98
126
  catch (e) {
99
127
  // ignore
100
128
  }
101
129
  }
102
130
  }
103
- export function command(command, args, options = {}) {
104
- const process = spawn(command, args, {
131
+ export function command(executable, args, options = {}) {
132
+ const process = spawn(executable, args, {
105
133
  shell: true,
106
134
  ...options,
107
- stdio: ["ignore", "pipe", "pipe"],
135
+ stdio: ['ignore', 'pipe', 'pipe'],
108
136
  });
109
137
  if (shellOutput) {
110
- console.log(`[SHELL] ${command} ${args.join(" ")} ${JSON.stringify(options)}`);
138
+ console.log(`[SHELL] ${executable} ${args.join(' ')} ${JSON.stringify(options)}`);
111
139
  }
112
140
  return new CommandImpl(process);
113
141
  }