@omni-oss/task-bench 0.0.0-beta.1
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.
- package/README.md +328 -0
- package/dist/bench/index.d.ts +82 -0
- package/dist/bench/index.d.ts.map +1 -0
- package/dist/bench/install.d.ts +5 -0
- package/dist/bench/install.d.ts.map +1 -0
- package/dist/bench/report.d.ts +9 -0
- package/dist/bench/report.d.ts.map +1 -0
- package/dist/bench/stats.d.ts +12 -0
- package/dist/bench/stats.d.ts.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/config.d.ts +91 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/generate/index.d.ts +19 -0
- package/dist/generate/index.d.ts.map +1 -0
- package/dist/generate/templates.d.ts +20 -0
- package/dist/generate/templates.d.ts.map +1 -0
- package/dist/graph.d.ts +21 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/src-D3XyMXAu.mjs +1167 -0
- package/dist/suite/index.d.ts +49 -0
- package/dist/suite/index.d.ts.map +1 -0
- package/dist/suite/preset.d.ts +93 -0
- package/dist/suite/preset.d.ts.map +1 -0
- package/dist/suite/report.d.ts +7 -0
- package/dist/suite/report.d.ts.map +1 -0
- package/dist/task-bench-cli.mjs +107 -0
- package/dist/tools/index.d.ts +18 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/moon.d.ts +10 -0
- package/dist/tools/moon.d.ts.map +1 -0
- package/dist/tools/nx.d.ts +7 -0
- package/dist/tools/nx.d.ts.map +1 -0
- package/dist/tools/omni.d.ts +7 -0
- package/dist/tools/omni.d.ts.map +1 -0
- package/dist/tools/turbo.d.ts +5 -0
- package/dist/tools/turbo.d.ts.map +1 -0
- package/dist/tools/types.d.ts +77 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/package.json +41 -0
- package/project.omni.yaml +33 -0
- package/src/bench/index.ts +323 -0
- package/src/bench/install.ts +12 -0
- package/src/bench/report.ts +142 -0
- package/src/bench/stats.spec.ts +35 -0
- package/src/bench/stats.ts +38 -0
- package/src/cli/index.ts +410 -0
- package/src/config.ts +138 -0
- package/src/generate/index.ts +215 -0
- package/src/generate/templates.ts +87 -0
- package/src/graph.spec.ts +119 -0
- package/src/graph.ts +120 -0
- package/src/index.ts +31 -0
- package/src/suite/index.ts +113 -0
- package/src/suite/preset.spec.ts +95 -0
- package/src/suite/preset.ts +253 -0
- package/src/suite/report.ts +135 -0
- package/src/tools/adapters.spec.ts +95 -0
- package/src/tools/config.spec.ts +73 -0
- package/src/tools/index.ts +76 -0
- package/src/tools/moon.ts +106 -0
- package/src/tools/nx.ts +106 -0
- package/src/tools/omni.ts +96 -0
- package/src/tools/turbo.ts +78 -0
- package/src/tools/types.ts +116 -0
- package/tsconfig.json +4 -0
- package/tsconfig.project.json +6 -0
- package/tsconfig.types.json +4 -0
- package/vite.config.ts +29 -0
- package/vitest.config.unit.ts +13 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
deepMerge,
|
|
4
|
+
getPreset,
|
|
5
|
+
listPresets,
|
|
6
|
+
parseSuite,
|
|
7
|
+
resolveScenario,
|
|
8
|
+
} from "./preset";
|
|
9
|
+
|
|
10
|
+
describe("deepMerge", () => {
|
|
11
|
+
it("recursively merges plain objects and replaces scalars/arrays", () => {
|
|
12
|
+
expect(
|
|
13
|
+
deepMerge(
|
|
14
|
+
{ a: 1, nested: { x: 1, y: 2 }, list: [1, 2] },
|
|
15
|
+
{ a: 2, nested: { y: 3, z: 4 }, list: [9] },
|
|
16
|
+
),
|
|
17
|
+
).toEqual({ a: 2, nested: { x: 1, y: 3, z: 4 }, list: [9] });
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("built-in presets", () => {
|
|
22
|
+
it("exposes the expected presets", () => {
|
|
23
|
+
expect(listPresets()).toEqual(
|
|
24
|
+
expect.arrayContaining([
|
|
25
|
+
"quick",
|
|
26
|
+
"shapes",
|
|
27
|
+
"scale",
|
|
28
|
+
"density",
|
|
29
|
+
"daemon",
|
|
30
|
+
"full",
|
|
31
|
+
]),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("throws a helpful error for an unknown preset", () => {
|
|
36
|
+
expect(() => getPreset("nope")).toThrow(/unknown preset/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("every built-in preset parses and resolves", () => {
|
|
40
|
+
for (const name of listPresets()) {
|
|
41
|
+
const suite = getPreset(name);
|
|
42
|
+
for (const scenario of suite.scenarios) {
|
|
43
|
+
const resolved = resolveScenario(suite, scenario);
|
|
44
|
+
expect(resolved.config.projects).toBeGreaterThan(0);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("resolveScenario", () => {
|
|
51
|
+
it("merges suite defaults under scenario overrides", () => {
|
|
52
|
+
const suite = getPreset("shapes");
|
|
53
|
+
const chain = suite.scenarios.find((s) => s.name === "shape-chain");
|
|
54
|
+
expect(chain).toBeDefined();
|
|
55
|
+
// biome-ignore lint/style/noNonNullAssertion: for testing
|
|
56
|
+
const resolved = resolveScenario(suite, chain!);
|
|
57
|
+
// From defaults:
|
|
58
|
+
expect(resolved.config.projects).toBe(120);
|
|
59
|
+
expect(resolved.run.concurrency).toBe(8);
|
|
60
|
+
// From the scenario override:
|
|
61
|
+
expect(resolved.config.dependency.strategy).toBe("chain");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("carries per-scenario run overrides (daemon on/off)", () => {
|
|
65
|
+
const suite = getPreset("daemon");
|
|
66
|
+
const off = suite.scenarios.find((s) => s.name === "daemon-off");
|
|
67
|
+
// biome-ignore lint/style/noNonNullAssertion: for testing
|
|
68
|
+
expect(resolveScenario(suite, off!).run.daemon).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("parseSuite", () => {
|
|
73
|
+
it("validates a custom suite object", () => {
|
|
74
|
+
const suite = parseSuite({
|
|
75
|
+
name: "custom",
|
|
76
|
+
scenarios: [
|
|
77
|
+
{ name: "a", config: { projects: 5 } },
|
|
78
|
+
{ name: "b", config: { projects: 6, tasksPerProject: 2 } },
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
expect(suite.scenarios).toHaveLength(2);
|
|
82
|
+
expect(
|
|
83
|
+
// biome-ignore lint/style/noNonNullAssertion: for testing
|
|
84
|
+
resolveScenario(suite, suite.scenarios[0]!).config.projects,
|
|
85
|
+
).toBe(5);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("rejects filesystem-unsafe scenario names", () => {
|
|
89
|
+
expect(() =>
|
|
90
|
+
parseSuite({
|
|
91
|
+
scenarios: [{ name: "bad/name", config: {} }],
|
|
92
|
+
}),
|
|
93
|
+
).toThrow();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
type HarnessConfig,
|
|
4
|
+
type HarnessConfigInput,
|
|
5
|
+
resolveConfig,
|
|
6
|
+
ToolSchema,
|
|
7
|
+
} from "../config";
|
|
8
|
+
|
|
9
|
+
/** Per-scenario benchmark run options (serializable subset of RunBenchmarkOptions). */
|
|
10
|
+
export const RunOptionsSchema = z
|
|
11
|
+
.object({
|
|
12
|
+
tools: z.array(ToolSchema).optional(),
|
|
13
|
+
task: z.string().optional(),
|
|
14
|
+
coldRuns: z.number().int().nonnegative().optional(),
|
|
15
|
+
warmRuns: z.number().int().nonnegative().optional(),
|
|
16
|
+
concurrency: z.number().int().positive().optional(),
|
|
17
|
+
daemon: z.boolean().optional(),
|
|
18
|
+
})
|
|
19
|
+
.default({});
|
|
20
|
+
|
|
21
|
+
export type RunOptions = z.infer<typeof RunOptionsSchema>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A harness config fragment. Kept loose here (merged then validated by
|
|
25
|
+
* `resolveConfig`) so scenarios can inherit from suite-level defaults.
|
|
26
|
+
*/
|
|
27
|
+
const ConfigFragmentSchema = z.record(z.string(), z.unknown()).default({});
|
|
28
|
+
|
|
29
|
+
export const ScenarioSchema = z.object({
|
|
30
|
+
name: z
|
|
31
|
+
.string()
|
|
32
|
+
.regex(
|
|
33
|
+
/^[a-zA-Z0-9._-]+$/,
|
|
34
|
+
"scenario name must be filesystem-safe (letters, digits, . _ -)",
|
|
35
|
+
),
|
|
36
|
+
description: z.string().optional(),
|
|
37
|
+
config: ConfigFragmentSchema,
|
|
38
|
+
run: RunOptionsSchema,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const SuiteSchema = z.object({
|
|
42
|
+
name: z.string().default("task-bench suite"),
|
|
43
|
+
description: z.string().optional(),
|
|
44
|
+
defaults: z
|
|
45
|
+
.object({
|
|
46
|
+
config: ConfigFragmentSchema,
|
|
47
|
+
run: RunOptionsSchema,
|
|
48
|
+
})
|
|
49
|
+
.prefault({}),
|
|
50
|
+
scenarios: z.array(ScenarioSchema).min(1),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export type SuiteConfig = z.infer<typeof SuiteSchema>;
|
|
54
|
+
export type SuiteConfigInput = z.input<typeof SuiteSchema>;
|
|
55
|
+
export type Scenario = z.infer<typeof ScenarioSchema>;
|
|
56
|
+
|
|
57
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
58
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Deep-merge plain objects; arrays and scalars from `override` replace `base`. */
|
|
62
|
+
export function deepMerge(
|
|
63
|
+
base: Record<string, unknown>,
|
|
64
|
+
override: Record<string, unknown>,
|
|
65
|
+
): Record<string, unknown> {
|
|
66
|
+
const out: Record<string, unknown> = { ...base };
|
|
67
|
+
for (const [key, value] of Object.entries(override)) {
|
|
68
|
+
const current = out[key];
|
|
69
|
+
out[key] =
|
|
70
|
+
isPlainObject(current) && isPlainObject(value)
|
|
71
|
+
? deepMerge(current, value)
|
|
72
|
+
: value;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ResolvedScenario {
|
|
78
|
+
name: string;
|
|
79
|
+
description?: string | undefined;
|
|
80
|
+
config: HarnessConfig;
|
|
81
|
+
run: RunOptions;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Merge a scenario over suite defaults and resolve/validate its config. */
|
|
85
|
+
export function resolveScenario(
|
|
86
|
+
suite: SuiteConfig,
|
|
87
|
+
scenario: Scenario,
|
|
88
|
+
): ResolvedScenario {
|
|
89
|
+
const mergedConfig = deepMerge(
|
|
90
|
+
suite.defaults.config,
|
|
91
|
+
scenario.config,
|
|
92
|
+
) as HarnessConfigInput;
|
|
93
|
+
const run: RunOptions = { ...suite.defaults.run, ...scenario.run };
|
|
94
|
+
return {
|
|
95
|
+
name: scenario.name,
|
|
96
|
+
description: scenario.description,
|
|
97
|
+
config: resolveConfig(mergedConfig),
|
|
98
|
+
run,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Built-in presets
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
const DEPENDENCY_STRATEGIES_FOR_SWEEP = [
|
|
107
|
+
"isolated",
|
|
108
|
+
"chain",
|
|
109
|
+
"fan-out",
|
|
110
|
+
"layered",
|
|
111
|
+
"random",
|
|
112
|
+
] as const;
|
|
113
|
+
|
|
114
|
+
const shapes: SuiteConfigInput = {
|
|
115
|
+
name: "dependency-shape sweep",
|
|
116
|
+
description:
|
|
117
|
+
"How the dependency-graph shape affects discovery/scheduling overhead at a fixed scale.",
|
|
118
|
+
defaults: {
|
|
119
|
+
config: { projects: 120, tasksPerProject: 3 },
|
|
120
|
+
run: { concurrency: 8, coldRuns: 2, warmRuns: 3 },
|
|
121
|
+
},
|
|
122
|
+
scenarios: DEPENDENCY_STRATEGIES_FOR_SWEEP.map((strategy) => ({
|
|
123
|
+
name: `shape-${strategy}`,
|
|
124
|
+
config: { dependency: { strategy } },
|
|
125
|
+
})),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const scale: SuiteConfigInput = {
|
|
129
|
+
name: "scale sweep",
|
|
130
|
+
description: "How overhead grows with workspace size (layered graph).",
|
|
131
|
+
defaults: {
|
|
132
|
+
config: {
|
|
133
|
+
tasksPerProject: 3,
|
|
134
|
+
dependency: { strategy: "layered", layers: 8 },
|
|
135
|
+
},
|
|
136
|
+
run: { concurrency: 8, coldRuns: 2, warmRuns: 3 },
|
|
137
|
+
},
|
|
138
|
+
scenarios: [50, 150, 300, 600].map((projects) => ({
|
|
139
|
+
name: `scale-${projects}`,
|
|
140
|
+
config: { projects },
|
|
141
|
+
})),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const density: SuiteConfigInput = {
|
|
145
|
+
name: "task-density sweep",
|
|
146
|
+
description: "How the number of tasks per project affects overhead.",
|
|
147
|
+
defaults: {
|
|
148
|
+
config: { projects: 120, dependency: { strategy: "layered" } },
|
|
149
|
+
run: { concurrency: 8, coldRuns: 2, warmRuns: 3 },
|
|
150
|
+
},
|
|
151
|
+
scenarios: [2, 5, 10].map((tasksPerProject) => ({
|
|
152
|
+
name: `density-${tasksPerProject}`,
|
|
153
|
+
config: { tasksPerProject },
|
|
154
|
+
})),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const daemon: SuiteConfigInput = {
|
|
158
|
+
name: "daemon on vs off",
|
|
159
|
+
description:
|
|
160
|
+
"Whether each tool's persistent daemon changes warm/cold overhead.",
|
|
161
|
+
defaults: {
|
|
162
|
+
config: {
|
|
163
|
+
projects: 200,
|
|
164
|
+
tasksPerProject: 3,
|
|
165
|
+
dependency: { strategy: "layered" },
|
|
166
|
+
},
|
|
167
|
+
run: { concurrency: 8, coldRuns: 2, warmRuns: 3 },
|
|
168
|
+
},
|
|
169
|
+
scenarios: [
|
|
170
|
+
{ name: "daemon-on", run: { daemon: true } },
|
|
171
|
+
{ name: "daemon-off", run: { daemon: false } },
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const quick: SuiteConfigInput = {
|
|
176
|
+
name: "quick smoke suite",
|
|
177
|
+
description: "Tiny, fast sanity sweep across two shapes.",
|
|
178
|
+
defaults: {
|
|
179
|
+
config: { projects: 30, tasksPerProject: 2 },
|
|
180
|
+
run: { concurrency: 8, coldRuns: 1, warmRuns: 2 },
|
|
181
|
+
},
|
|
182
|
+
scenarios: [
|
|
183
|
+
{
|
|
184
|
+
name: "quick-isolated",
|
|
185
|
+
config: { dependency: { strategy: "isolated" } },
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: "quick-layered",
|
|
189
|
+
config: { dependency: { strategy: "layered" } },
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const full: SuiteConfigInput = {
|
|
195
|
+
name: "full suite",
|
|
196
|
+
description: "Shapes + scale + density in one run.",
|
|
197
|
+
defaults: { run: { concurrency: 8, coldRuns: 2, warmRuns: 3 } },
|
|
198
|
+
scenarios: [
|
|
199
|
+
...DEPENDENCY_STRATEGIES_FOR_SWEEP.map((strategy) => ({
|
|
200
|
+
name: `shape-${strategy}`,
|
|
201
|
+
config: {
|
|
202
|
+
projects: 120,
|
|
203
|
+
tasksPerProject: 3,
|
|
204
|
+
dependency: { strategy },
|
|
205
|
+
},
|
|
206
|
+
})),
|
|
207
|
+
...[50, 150, 300].map((projects) => ({
|
|
208
|
+
name: `scale-${projects}`,
|
|
209
|
+
config: {
|
|
210
|
+
projects,
|
|
211
|
+
tasksPerProject: 3,
|
|
212
|
+
dependency: { strategy: "layered" as const, layers: 8 },
|
|
213
|
+
},
|
|
214
|
+
})),
|
|
215
|
+
...[2, 5, 10].map((tasksPerProject) => ({
|
|
216
|
+
name: `density-${tasksPerProject}`,
|
|
217
|
+
config: {
|
|
218
|
+
projects: 120,
|
|
219
|
+
tasksPerProject,
|
|
220
|
+
dependency: { strategy: "layered" as const },
|
|
221
|
+
},
|
|
222
|
+
})),
|
|
223
|
+
],
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export const BUILTIN_PRESETS: Record<string, SuiteConfigInput> = {
|
|
227
|
+
quick,
|
|
228
|
+
shapes,
|
|
229
|
+
scale,
|
|
230
|
+
density,
|
|
231
|
+
daemon,
|
|
232
|
+
full,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export function listPresets(): string[] {
|
|
236
|
+
return Object.keys(BUILTIN_PRESETS);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Resolve a built-in preset by name, or throw with the list of valid names. */
|
|
240
|
+
export function getPreset(name: string): SuiteConfig {
|
|
241
|
+
const preset = BUILTIN_PRESETS[name];
|
|
242
|
+
if (!preset) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`unknown preset "${name}" (available: ${listPresets().join(", ")})`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return SuiteSchema.parse(preset);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Parse and validate a suite preset object (e.g. loaded from a JSON file). */
|
|
251
|
+
export function parseSuite(input: unknown): SuiteConfig {
|
|
252
|
+
return SuiteSchema.parse(input);
|
|
253
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { formatReport } from "../bench/report";
|
|
2
|
+
import { formatMs } from "../bench/stats";
|
|
3
|
+
import { TOOLS, type Tool } from "../config";
|
|
4
|
+
import type { SuiteResult, SuiteScenarioResult } from "./index";
|
|
5
|
+
|
|
6
|
+
/** Tools that appear in at least one scenario, in canonical order. */
|
|
7
|
+
function toolsInSuite(suite: SuiteResult): Tool[] {
|
|
8
|
+
const present = new Set<Tool>();
|
|
9
|
+
for (const s of suite.scenarios) {
|
|
10
|
+
for (const t of s.result.tools) present.add(t.tool);
|
|
11
|
+
}
|
|
12
|
+
return TOOLS.filter((t) => present.has(t));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** One-line summary of the resolved tool versions used across the suite. */
|
|
16
|
+
function formatSuiteVersions(suite: SuiteResult, tools: Tool[]): string {
|
|
17
|
+
const parts = tools.map((tool) => {
|
|
18
|
+
const version = suite.scenarios
|
|
19
|
+
.map((s) => s.result.versions[tool])
|
|
20
|
+
.find((v) => v != null);
|
|
21
|
+
return `\`${tool} ${version ?? "?"}\``;
|
|
22
|
+
});
|
|
23
|
+
return `Tool versions:\n\n${parts.join("\n\n")}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function warmCell(scenario: SuiteScenarioResult, tool: Tool): string {
|
|
27
|
+
const t = scenario.result.tools.find((r) => r.tool === tool);
|
|
28
|
+
if (!t || t.error) return t?.error ? "err" : "—";
|
|
29
|
+
const hits =
|
|
30
|
+
t.taskGraphSize > 0
|
|
31
|
+
? (t.taskGraphSize - t.warm.executedMedian) / t.taskGraphSize
|
|
32
|
+
: 1;
|
|
33
|
+
const value = formatMs(t.warm.stats.median);
|
|
34
|
+
return hits < 0.995 ? `${value}⚠` : value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function coldCell(scenario: SuiteScenarioResult, tool: Tool): string {
|
|
38
|
+
const t = scenario.result.tools.find((r) => r.tool === tool);
|
|
39
|
+
if (!t || t.error) return t?.error ? "err" : "—";
|
|
40
|
+
return formatMs(t.cold.stats.median);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function table(headers: string[], rows: string[][]): string[] {
|
|
44
|
+
const widths = headers.map((h, i) =>
|
|
45
|
+
Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)),
|
|
46
|
+
);
|
|
47
|
+
const pad = (v: string, w: number) => v.padEnd(w);
|
|
48
|
+
const line = (cells: string[]) =>
|
|
49
|
+
`| ${cells.map((c, i) => pad(c, widths[i] ?? 0)).join(" | ")} |`;
|
|
50
|
+
return [
|
|
51
|
+
line(headers),
|
|
52
|
+
`| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`,
|
|
53
|
+
...rows.map(line),
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Render a full suite as Markdown: two summary matrices (warm + cold median per
|
|
59
|
+
* tool per scenario) followed by the detailed per-scenario report tables.
|
|
60
|
+
*/
|
|
61
|
+
export function formatSuiteMarkdown(suite: SuiteResult): string {
|
|
62
|
+
const tools = toolsInSuite(suite);
|
|
63
|
+
const lines: string[] = [];
|
|
64
|
+
|
|
65
|
+
lines.push(`# ${suite.name}`);
|
|
66
|
+
lines.push("");
|
|
67
|
+
lines.push(`TaskBench v${suite.taskBenchVersion}`);
|
|
68
|
+
lines.push("");
|
|
69
|
+
if (suite.description) {
|
|
70
|
+
lines.push(suite.description);
|
|
71
|
+
lines.push("");
|
|
72
|
+
}
|
|
73
|
+
lines.push(
|
|
74
|
+
`Generated ${suite.generatedAt} · ${suite.scenarios.length} scenario(s).`,
|
|
75
|
+
);
|
|
76
|
+
lines.push("");
|
|
77
|
+
lines.push(formatSuiteVersions(suite, tools));
|
|
78
|
+
lines.push("");
|
|
79
|
+
lines.push(
|
|
80
|
+
"`warm` = median wall time with a verified 100% cache hit " +
|
|
81
|
+
"(discovery + cache-restore overhead). `⚠` marks a scenario whose " +
|
|
82
|
+
"warm runs were not fully cached. Absolute times are " +
|
|
83
|
+
"hardware-dependent — read the ratios.",
|
|
84
|
+
);
|
|
85
|
+
lines.push("");
|
|
86
|
+
|
|
87
|
+
// Warm summary.
|
|
88
|
+
lines.push("## Summary — warm median");
|
|
89
|
+
lines.push("");
|
|
90
|
+
lines.push(
|
|
91
|
+
...table(
|
|
92
|
+
["scenario", "nodes", "conc", "daemon", ...tools],
|
|
93
|
+
suite.scenarios.map((s) => [
|
|
94
|
+
s.name,
|
|
95
|
+
String(s.result.tools[0]?.taskGraphSize ?? 0),
|
|
96
|
+
String(s.result.concurrency),
|
|
97
|
+
s.result.daemon ? "on" : "off",
|
|
98
|
+
...tools.map((t) => warmCell(s, t)),
|
|
99
|
+
]),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
lines.push("");
|
|
103
|
+
|
|
104
|
+
// Cold summary.
|
|
105
|
+
lines.push("## Summary — cold median");
|
|
106
|
+
lines.push("");
|
|
107
|
+
lines.push(
|
|
108
|
+
...table(
|
|
109
|
+
["scenario", "nodes", "conc", ...tools],
|
|
110
|
+
suite.scenarios.map((s) => [
|
|
111
|
+
s.name,
|
|
112
|
+
String(s.result.tools[0]?.taskGraphSize ?? 0),
|
|
113
|
+
String(s.result.concurrency),
|
|
114
|
+
...tools.map((t) => coldCell(s, t)),
|
|
115
|
+
]),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
lines.push("");
|
|
119
|
+
|
|
120
|
+
// Details.
|
|
121
|
+
lines.push("## Details");
|
|
122
|
+
lines.push("");
|
|
123
|
+
for (const s of suite.scenarios) {
|
|
124
|
+
const heading = s.description ? `${s.name} — ${s.description}` : s.name;
|
|
125
|
+
lines.push(`### ${heading}`);
|
|
126
|
+
lines.push(
|
|
127
|
+
`Config: ${s.config.projects} projects × ${s.config.tasksPerProject} tasks, ` +
|
|
128
|
+
`strategy \`${s.config.dependency.strategy}\`.`,
|
|
129
|
+
);
|
|
130
|
+
lines.push(formatReport(s.result).trimEnd());
|
|
131
|
+
lines.push("");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return lines.join("\n");
|
|
135
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveConfig } from "../config";
|
|
3
|
+
import { assertSupportedVersion, getAdapter, type ToolContext } from "./index";
|
|
4
|
+
|
|
5
|
+
const ctx = (daemon: boolean): ToolContext => ({
|
|
6
|
+
rootDir: "/tmp/x",
|
|
7
|
+
projectDirs: ["packages/a"],
|
|
8
|
+
concurrency: 4,
|
|
9
|
+
daemon,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("tool adapters (runtime)", () => {
|
|
13
|
+
it("omni has no daemon and pins concurrency", () => {
|
|
14
|
+
const omni = getAdapter("omni");
|
|
15
|
+
expect(omni.hasDaemon).toBe(false);
|
|
16
|
+
expect(omni.run("t2", ctx(true)).args).toEqual([
|
|
17
|
+
"run",
|
|
18
|
+
"t2",
|
|
19
|
+
"-u",
|
|
20
|
+
"stream",
|
|
21
|
+
"-c",
|
|
22
|
+
"4",
|
|
23
|
+
]);
|
|
24
|
+
expect(omni.env(ctx(true))).toEqual({});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("turbo toggles the daemon via run flags", () => {
|
|
28
|
+
const turbo = getAdapter("turbo");
|
|
29
|
+
expect(turbo.hasDaemon).toBe(true);
|
|
30
|
+
expect(turbo.run("t2", ctx(true)).args).toContain("--daemon");
|
|
31
|
+
expect(turbo.run("t2", ctx(true)).args).toContain("--concurrency=4");
|
|
32
|
+
expect(turbo.run("t2", ctx(false)).args).toContain("--no-daemon");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("nx toggles the daemon via NX_DAEMON and pins parallelism", () => {
|
|
36
|
+
const nx = getAdapter("nx");
|
|
37
|
+
expect(nx.hasDaemon).toBe(true);
|
|
38
|
+
expect(nx.run("t2", ctx(true)).args).toContain("--parallel=4");
|
|
39
|
+
expect(nx.env(ctx(true)).NX_DAEMON).toBe("true");
|
|
40
|
+
expect(nx.env(ctx(false)).NX_DAEMON).toBe("false");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("moon has no daemon and runs the :task target", () => {
|
|
44
|
+
const moon = getAdapter("moon");
|
|
45
|
+
expect(moon.hasDaemon).toBe(false);
|
|
46
|
+
expect(moon.run("t2", ctx(true)).args).toEqual([
|
|
47
|
+
"run",
|
|
48
|
+
":t2",
|
|
49
|
+
"--concurrency",
|
|
50
|
+
"4",
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("tool adapters (versions & dependencies)", () => {
|
|
56
|
+
const config = resolveConfig();
|
|
57
|
+
|
|
58
|
+
it("declares supported version ranges and derives deps from config", () => {
|
|
59
|
+
expect(getAdapter("turbo").devDependencies(config)).toEqual({
|
|
60
|
+
turbo: config.versions.turbo,
|
|
61
|
+
});
|
|
62
|
+
expect(getAdapter("nx").devDependencies(config)).toEqual({
|
|
63
|
+
nx: config.versions.nx,
|
|
64
|
+
});
|
|
65
|
+
expect(getAdapter("moon").devDependencies(config)).toEqual({
|
|
66
|
+
"@moonrepo/cli": config.versions.moon,
|
|
67
|
+
});
|
|
68
|
+
// omni is external and installs nothing.
|
|
69
|
+
expect(getAdapter("omni").devDependencies(config)).toEqual({});
|
|
70
|
+
expect(getAdapter("omni").pinnedVersion(config)).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("accepts the default pinned versions", () => {
|
|
74
|
+
for (const tool of ["turbo", "nx", "moon"] as const) {
|
|
75
|
+
const adapter = getAdapter(tool);
|
|
76
|
+
const version = adapter.pinnedVersion(config);
|
|
77
|
+
expect(version).not.toBeNull();
|
|
78
|
+
expect(() =>
|
|
79
|
+
assertSupportedVersion(adapter, version as string),
|
|
80
|
+
).not.toThrow();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("rejects an unsupported version", () => {
|
|
85
|
+
expect(() =>
|
|
86
|
+
assertSupportedVersion(getAdapter("turbo"), "1.5.0"),
|
|
87
|
+
).toThrow(/not supported/);
|
|
88
|
+
expect(() =>
|
|
89
|
+
assertSupportedVersion(getAdapter("nx"), "19.0.0"),
|
|
90
|
+
).toThrow(/not supported/);
|
|
91
|
+
expect(() =>
|
|
92
|
+
assertSupportedVersion(getAdapter("moon"), "1.0.0"),
|
|
93
|
+
).toThrow(/not supported/);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parse as parseYaml } from "yaml";
|
|
3
|
+
import { resolveConfig } from "../config";
|
|
4
|
+
import { buildGraph, type ProjectNode } from "../graph";
|
|
5
|
+
import { moonProjectConfig } from "./moon";
|
|
6
|
+
import { nxProjectConfig, nxRootConfig } from "./nx";
|
|
7
|
+
import { omniProjectConfig } from "./omni";
|
|
8
|
+
import { turboRootConfig } from "./turbo";
|
|
9
|
+
import { taskDependencies } from "./types";
|
|
10
|
+
|
|
11
|
+
describe("taskDependencies", () => {
|
|
12
|
+
it("chains within a project and fans upstream by default", () => {
|
|
13
|
+
const config = resolveConfig();
|
|
14
|
+
expect(taskDependencies(config, 0)).toEqual(["^t0"]);
|
|
15
|
+
expect(taskDependencies(config, 2)).toEqual(["t1", "^t2"]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("respects disabled chaining / fan-out", () => {
|
|
19
|
+
const config = resolveConfig({
|
|
20
|
+
task: { chainWithinProject: false, fanUpstream: false },
|
|
21
|
+
});
|
|
22
|
+
expect(taskDependencies(config, 2)).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("equivalence across runners", () => {
|
|
27
|
+
const config = resolveConfig({
|
|
28
|
+
projects: 6,
|
|
29
|
+
tasksPerProject: 2,
|
|
30
|
+
dependency: { strategy: "chain" },
|
|
31
|
+
});
|
|
32
|
+
const projects = buildGraph(config);
|
|
33
|
+
const project = projects[3] as ProjectNode;
|
|
34
|
+
const parent = projects[2] as ProjectNode;
|
|
35
|
+
|
|
36
|
+
it("omni encodes the same task deps and project deps", () => {
|
|
37
|
+
const doc = parseYaml(omniProjectConfig(config, project, projects));
|
|
38
|
+
expect(doc.name).toBe(project.name);
|
|
39
|
+
expect(doc.dependencies).toEqual([parent.name]);
|
|
40
|
+
expect(doc.tasks.t1.dependencies).toEqual(["t0", "^t1"]);
|
|
41
|
+
expect(doc.tasks.t1.cache.output.files).toEqual(["dist/t1.*"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("turbo encodes matching dependsOn / outputs", () => {
|
|
45
|
+
const turbo = JSON.parse(turboRootConfig(config));
|
|
46
|
+
expect(turbo.tasks.t1.dependsOn).toEqual(["t0", "^t1"]);
|
|
47
|
+
expect(turbo.tasks.t1.outputs).toEqual(["dist/t1.*"]);
|
|
48
|
+
expect(turbo.globalPassThroughEnv).toContain("TASK_BENCH_EXEC_LOG");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("nx encodes matching dependsOn / outputs and per-project targets", () => {
|
|
52
|
+
const nx = JSON.parse(nxRootConfig(config));
|
|
53
|
+
expect(nx.targetDefaults.t1.dependsOn).toEqual(["t0", "^t1"]);
|
|
54
|
+
expect(nx.targetDefaults.t1.outputs).toEqual([
|
|
55
|
+
"{projectRoot}/dist/t1.*",
|
|
56
|
+
]);
|
|
57
|
+
expect(nx.targetDefaults.t1.cache).toBe(true);
|
|
58
|
+
|
|
59
|
+
const proj = JSON.parse(nxProjectConfig(config, project));
|
|
60
|
+
expect(proj.name).toBe(project.name);
|
|
61
|
+
expect(proj.targets.t1.executor).toBe("nx:run-commands");
|
|
62
|
+
expect(proj.targets.t1.options.command).toBe("node ./task.mjs t1");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("moon encodes matching deps / outputs and project deps", () => {
|
|
66
|
+
const doc = parseYaml(moonProjectConfig(config, project, projects));
|
|
67
|
+
expect(doc.id).toBe(project.name);
|
|
68
|
+
expect(doc.dependsOn).toEqual([parent.name]);
|
|
69
|
+
expect(doc.tasks.t1.deps).toEqual(["~:t0", "^:t1"]);
|
|
70
|
+
expect(doc.tasks.t1.command).toBe("node ./task.mjs t1");
|
|
71
|
+
expect(doc.tasks.t1.outputs).toEqual(["dist/t1.*"]);
|
|
72
|
+
});
|
|
73
|
+
});
|