@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,215 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { execa } from "execa";
|
|
4
|
+
import {
|
|
5
|
+
type HarnessConfig,
|
|
6
|
+
type HarnessConfigInput,
|
|
7
|
+
resolveConfig,
|
|
8
|
+
} from "../config";
|
|
9
|
+
import { buildGraph, type ProjectNode, taskNames } from "../graph";
|
|
10
|
+
import { getAdapters, resolveToolVersions, type ToolAdapter } from "../tools";
|
|
11
|
+
import { sourceFile, taskRunner } from "./templates";
|
|
12
|
+
|
|
13
|
+
export interface GenerateResult {
|
|
14
|
+
rootDir: string;
|
|
15
|
+
config: HarnessConfig;
|
|
16
|
+
projects: ProjectNode[];
|
|
17
|
+
/** Every file written, workspace-relative. */
|
|
18
|
+
files: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function writeText(
|
|
22
|
+
rootDir: string,
|
|
23
|
+
relPath: string,
|
|
24
|
+
contents: string,
|
|
25
|
+
written: string[],
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const abs = join(rootDir, relPath);
|
|
28
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
29
|
+
await writeFile(abs, contents);
|
|
30
|
+
written.push(relPath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rootPackageJson(
|
|
34
|
+
config: HarnessConfig,
|
|
35
|
+
adapters: ToolAdapter[],
|
|
36
|
+
): string {
|
|
37
|
+
// Each tool contributes its own npm dependencies (decoupled).
|
|
38
|
+
const devDependencies: Record<string, string> = {};
|
|
39
|
+
for (const adapter of adapters) {
|
|
40
|
+
Object.assign(devDependencies, adapter.devDependencies(config));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return `${JSON.stringify(
|
|
44
|
+
{
|
|
45
|
+
name: "task-bench-harness",
|
|
46
|
+
private: true,
|
|
47
|
+
packageManager: `bun@${config.versions.bun}`,
|
|
48
|
+
workspaces: ["packages/*"],
|
|
49
|
+
...(Object.keys(devDependencies).length ? { devDependencies } : {}),
|
|
50
|
+
},
|
|
51
|
+
null,
|
|
52
|
+
2,
|
|
53
|
+
)}\n`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function projectPackageJson(
|
|
57
|
+
config: HarnessConfig,
|
|
58
|
+
project: ProjectNode,
|
|
59
|
+
projects: ProjectNode[],
|
|
60
|
+
): string {
|
|
61
|
+
const scripts: Record<string, string> = {};
|
|
62
|
+
for (const task of taskNames(config)) {
|
|
63
|
+
scripts[task] = `node ./task.mjs ${task}`;
|
|
64
|
+
}
|
|
65
|
+
const dependencies: Record<string, string> = {};
|
|
66
|
+
for (const depIndex of project.dependencies) {
|
|
67
|
+
const dep = projects[depIndex];
|
|
68
|
+
if (dep) dependencies[dep.name] = "workspace:*";
|
|
69
|
+
}
|
|
70
|
+
return `${JSON.stringify(
|
|
71
|
+
{
|
|
72
|
+
name: project.name,
|
|
73
|
+
version: "0.0.0",
|
|
74
|
+
private: true,
|
|
75
|
+
scripts,
|
|
76
|
+
...(Object.keys(dependencies).length ? { dependencies } : {}),
|
|
77
|
+
},
|
|
78
|
+
null,
|
|
79
|
+
2,
|
|
80
|
+
)}\n`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function gitignore(): string {
|
|
84
|
+
return [
|
|
85
|
+
"node_modules",
|
|
86
|
+
"dist",
|
|
87
|
+
".turbo",
|
|
88
|
+
".nx",
|
|
89
|
+
".omni",
|
|
90
|
+
".moon/cache",
|
|
91
|
+
"*.log",
|
|
92
|
+
"",
|
|
93
|
+
].join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readme(config: HarnessConfig): string {
|
|
97
|
+
return [
|
|
98
|
+
"# task-bench harness",
|
|
99
|
+
"",
|
|
100
|
+
"Auto-generated benchmark workspace. Do not edit by hand; regenerate via",
|
|
101
|
+
"`@omni-oss/task-bench` instead.",
|
|
102
|
+
"",
|
|
103
|
+
`- projects: **${config.projects}**`,
|
|
104
|
+
`- tasks per project: **${config.tasksPerProject}**`,
|
|
105
|
+
`- dependency strategy: **${config.dependency.strategy}**`,
|
|
106
|
+
`- tools: **${config.tools.join(", ")}**`,
|
|
107
|
+
"",
|
|
108
|
+
"The exact configuration is captured in `bench.config.json`.",
|
|
109
|
+
"",
|
|
110
|
+
].join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* moon only enables its cache when the workspace is a git repository, so
|
|
115
|
+
* initialize + commit one. This is harmless (and realistic) for the other
|
|
116
|
+
* runners. Failures (e.g. git missing) are ignored.
|
|
117
|
+
*/
|
|
118
|
+
async function initGitRepo(rootDir: string): Promise<void> {
|
|
119
|
+
const opts = { cwd: rootDir, reject: false, stdio: "ignore" as const };
|
|
120
|
+
await execa("git", ["init", "-q"], opts);
|
|
121
|
+
await execa("git", ["add", "-A"], opts);
|
|
122
|
+
await execa(
|
|
123
|
+
"git",
|
|
124
|
+
[
|
|
125
|
+
"-c",
|
|
126
|
+
"user.email=bench@task-bench.local",
|
|
127
|
+
"-c",
|
|
128
|
+
"user.name=task-bench",
|
|
129
|
+
"commit",
|
|
130
|
+
"-qm",
|
|
131
|
+
"generated benchmark workspace",
|
|
132
|
+
],
|
|
133
|
+
opts,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Generate a complete benchmark workspace at `rootDir`. Existing contents at
|
|
139
|
+
* `rootDir` are removed first so the workspace is reproducible.
|
|
140
|
+
*
|
|
141
|
+
* The generator only produces the neutral, tool-agnostic workspace (projects,
|
|
142
|
+
* sources, task runners). Each enabled tool then writes its own configuration
|
|
143
|
+
* via its adapter, keeping the tools fully decoupled.
|
|
144
|
+
*/
|
|
145
|
+
export async function generateWorkspace(
|
|
146
|
+
rootDir: string,
|
|
147
|
+
input?: HarnessConfigInput,
|
|
148
|
+
): Promise<GenerateResult> {
|
|
149
|
+
const config = resolveConfig(input);
|
|
150
|
+
const projects = buildGraph(config);
|
|
151
|
+
const written: string[] = [];
|
|
152
|
+
const adapters = getAdapters(config.tools);
|
|
153
|
+
|
|
154
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
155
|
+
await mkdir(rootDir, { recursive: true });
|
|
156
|
+
|
|
157
|
+
// Validate every enabled tool's version up-front (fails fast).
|
|
158
|
+
const versions = await resolveToolVersions(config, rootDir);
|
|
159
|
+
|
|
160
|
+
// Neutral, tool-agnostic workspace.
|
|
161
|
+
await writeText(
|
|
162
|
+
rootDir,
|
|
163
|
+
"package.json",
|
|
164
|
+
rootPackageJson(config, adapters),
|
|
165
|
+
written,
|
|
166
|
+
);
|
|
167
|
+
await writeText(rootDir, ".gitignore", gitignore(), written);
|
|
168
|
+
await writeText(rootDir, "README.md", readme(config), written);
|
|
169
|
+
await writeText(
|
|
170
|
+
rootDir,
|
|
171
|
+
"bench.config.json",
|
|
172
|
+
`${JSON.stringify(config, null, 2)}\n`,
|
|
173
|
+
written,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
for (const project of projects) {
|
|
177
|
+
const dir = project.dir;
|
|
178
|
+
await writeText(
|
|
179
|
+
rootDir,
|
|
180
|
+
`${dir}/package.json`,
|
|
181
|
+
projectPackageJson(config, project, projects),
|
|
182
|
+
written,
|
|
183
|
+
);
|
|
184
|
+
await writeText(
|
|
185
|
+
rootDir,
|
|
186
|
+
`${dir}/src/index.js`,
|
|
187
|
+
sourceFile(project),
|
|
188
|
+
written,
|
|
189
|
+
);
|
|
190
|
+
await writeText(
|
|
191
|
+
rootDir,
|
|
192
|
+
`${dir}/task.mjs`,
|
|
193
|
+
taskRunner(config, project),
|
|
194
|
+
written,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Let each tool derive and write its own configuration.
|
|
199
|
+
const write = (relPath: string, contents: string) =>
|
|
200
|
+
writeText(rootDir, relPath, contents, written);
|
|
201
|
+
for (const adapter of adapters) {
|
|
202
|
+
await adapter.setup({
|
|
203
|
+
rootDir,
|
|
204
|
+
config,
|
|
205
|
+
projects,
|
|
206
|
+
version: versions.get(adapter.tool) ?? null,
|
|
207
|
+
write,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// moon requires a git repo to enable its cache.
|
|
212
|
+
await initGitRepo(rootDir);
|
|
213
|
+
|
|
214
|
+
return { rootDir, config, projects, files: written };
|
|
215
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { HarnessConfig } from "../config";
|
|
2
|
+
import type { ProjectNode } from "../graph";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The minimal source file each project ships. It exists so that every runner
|
|
6
|
+
* has a real input file to hash for cache keys.
|
|
7
|
+
*/
|
|
8
|
+
export function sourceFile(project: ProjectNode): string {
|
|
9
|
+
return [
|
|
10
|
+
`// Auto-generated source for ${project.name}.`,
|
|
11
|
+
`// Edit the harness config, not this file.`,
|
|
12
|
+
`export const id = ${JSON.stringify(project.name)};`,
|
|
13
|
+
`export const index = ${project.index};`,
|
|
14
|
+
`export const answer = 42;`,
|
|
15
|
+
"",
|
|
16
|
+
].join("\n");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The task runner every project uses. It is intentionally tiny but does real
|
|
21
|
+
* work that exercises each runner's caching + log-capture machinery:
|
|
22
|
+
* - reads its own source (a cache input),
|
|
23
|
+
* - performs a deterministic CPU loop,
|
|
24
|
+
* - prints a configurable number of log lines to stdout,
|
|
25
|
+
* - writes deterministic output file(s) into dist/ (cache outputs).
|
|
26
|
+
*
|
|
27
|
+
* Determinism matters: identical inputs => identical outputs => cache hits on
|
|
28
|
+
* warm runs, which is exactly what we want to measure.
|
|
29
|
+
*/
|
|
30
|
+
export function taskRunner(
|
|
31
|
+
config: HarnessConfig,
|
|
32
|
+
project: ProjectNode,
|
|
33
|
+
): string {
|
|
34
|
+
const { logLines, workIterations, outputFiles } = config.task;
|
|
35
|
+
return `#!/usr/bin/env node
|
|
36
|
+
import { createHash } from "node:crypto";
|
|
37
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
38
|
+
import { dirname, join } from "node:path";
|
|
39
|
+
import { fileURLToPath } from "node:url";
|
|
40
|
+
|
|
41
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
const PROJECT = ${JSON.stringify(project.name)};
|
|
43
|
+
const LOG_LINES = ${logLines};
|
|
44
|
+
const WORK_ITERATIONS = ${workIterations};
|
|
45
|
+
const OUTPUT_FILES = ${outputFiles};
|
|
46
|
+
|
|
47
|
+
const task = process.argv[2] ?? "task";
|
|
48
|
+
|
|
49
|
+
// Ground-truth execution marker: this line is only ever reached when the task
|
|
50
|
+
// actually runs. Cache hits skip the process entirely, so counting the lines
|
|
51
|
+
// in this out-of-tree log yields a tool-agnostic cache-hit rate. The log path
|
|
52
|
+
// lives outside the workspace so no runner hashes it as an input.
|
|
53
|
+
const execLog = process.env.TASK_BENCH_EXEC_LOG;
|
|
54
|
+
if (execLog) {
|
|
55
|
+
appendFileSync(execLog, \`\${PROJECT}\\t\${task}\\n\`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const source = readFileSync(join(HERE, "src", "index.js"), "utf8");
|
|
59
|
+
|
|
60
|
+
// Cheap, deterministic CPU work.
|
|
61
|
+
let acc = 0;
|
|
62
|
+
for (let i = 0; i < WORK_ITERATIONS; i++) {
|
|
63
|
+
acc = (acc + Math.imul(i ^ acc, 2654435761)) >>> 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const digest = createHash("sha256")
|
|
67
|
+
.update(source)
|
|
68
|
+
.update(task)
|
|
69
|
+
.update(String(acc))
|
|
70
|
+
.digest("hex");
|
|
71
|
+
|
|
72
|
+
for (let i = 1; i <= LOG_LINES; i++) {
|
|
73
|
+
console.log(\`[\${PROJECT}] \${task}: step \${i}/\${LOG_LINES} digest=\${digest.slice(0, 12)}\`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const outDir = join(HERE, "dist");
|
|
77
|
+
mkdirSync(outDir, { recursive: true });
|
|
78
|
+
for (let f = 0; f < OUTPUT_FILES; f++) {
|
|
79
|
+
writeFileSync(
|
|
80
|
+
join(outDir, \`\${task}.\${f}.txt\`),
|
|
81
|
+
\`\${PROJECT}\\t\${task}\\t\${digest}\\n\`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log(\`[\${PROJECT}] \${task}: complete (\${OUTPUT_FILES} output file(s))\`);
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveConfig } from "./config";
|
|
3
|
+
import { buildGraph, projectName, taskNames } from "./graph";
|
|
4
|
+
|
|
5
|
+
describe("resolveConfig", () => {
|
|
6
|
+
it("applies defaults", () => {
|
|
7
|
+
const config = resolveConfig();
|
|
8
|
+
expect(config.projects).toBe(50);
|
|
9
|
+
expect(config.tasksPerProject).toBe(3);
|
|
10
|
+
expect(config.dependency.strategy).toBe("layered");
|
|
11
|
+
expect(config.tools).toEqual(["omni", "turbo", "nx", "moon"]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("merges partial overrides", () => {
|
|
15
|
+
const config = resolveConfig({
|
|
16
|
+
projects: 8,
|
|
17
|
+
dependency: { strategy: "chain" },
|
|
18
|
+
});
|
|
19
|
+
expect(config.projects).toBe(8);
|
|
20
|
+
expect(config.dependency.strategy).toBe("chain");
|
|
21
|
+
// Untouched nested defaults remain.
|
|
22
|
+
expect(config.dependency.fanout).toBe(3);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("projectName", () => {
|
|
27
|
+
it("zero-pads to a stable width", () => {
|
|
28
|
+
const config = resolveConfig({
|
|
29
|
+
projects: 12,
|
|
30
|
+
projectPrefix: "bench-p",
|
|
31
|
+
});
|
|
32
|
+
expect(projectName(config, 0)).toBe("bench-p0000");
|
|
33
|
+
expect(projectName(config, 11)).toBe("bench-p0011");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("taskNames", () => {
|
|
38
|
+
it("generates t0..tN-1", () => {
|
|
39
|
+
expect(taskNames(resolveConfig({ tasksPerProject: 3 }))).toEqual([
|
|
40
|
+
"t0",
|
|
41
|
+
"t1",
|
|
42
|
+
"t2",
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("buildGraph strategies", () => {
|
|
48
|
+
const base = { projects: 10 } as const;
|
|
49
|
+
|
|
50
|
+
it("isolated has no edges", () => {
|
|
51
|
+
const nodes = buildGraph(
|
|
52
|
+
resolveConfig({ ...base, dependency: { strategy: "isolated" } }),
|
|
53
|
+
);
|
|
54
|
+
expect(nodes.every((n) => n.dependencies.length === 0)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("chain links each project to its predecessor", () => {
|
|
58
|
+
const nodes = buildGraph(
|
|
59
|
+
resolveConfig({ ...base, dependency: { strategy: "chain" } }),
|
|
60
|
+
);
|
|
61
|
+
expect(nodes[0]?.dependencies).toEqual([]);
|
|
62
|
+
expect(nodes[5]?.dependencies).toEqual([4]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("fan-out points every project at the root", () => {
|
|
66
|
+
const nodes = buildGraph(
|
|
67
|
+
resolveConfig({ ...base, dependency: { strategy: "fan-out" } }),
|
|
68
|
+
);
|
|
69
|
+
expect(nodes[0]?.dependencies).toEqual([]);
|
|
70
|
+
for (let i = 1; i < nodes.length; i++) {
|
|
71
|
+
expect(nodes[i]?.dependencies).toEqual([0]);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("layered only depends on the previous layer", () => {
|
|
76
|
+
const config = resolveConfig({
|
|
77
|
+
projects: 10,
|
|
78
|
+
dependency: { strategy: "layered", layers: 5, fanout: 2 },
|
|
79
|
+
});
|
|
80
|
+
const nodes = buildGraph(config);
|
|
81
|
+
const perLayer = Math.ceil(10 / 5); // 2
|
|
82
|
+
for (const node of nodes) {
|
|
83
|
+
const layer = Math.floor(node.index / perLayer);
|
|
84
|
+
for (const dep of node.dependencies) {
|
|
85
|
+
expect(Math.floor(dep / perLayer)).toBe(layer - 1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("is always acyclic (deps point to lower indices)", () => {
|
|
91
|
+
for (const strategy of [
|
|
92
|
+
"chain",
|
|
93
|
+
"fan-out",
|
|
94
|
+
"layered",
|
|
95
|
+
"random",
|
|
96
|
+
] as const) {
|
|
97
|
+
const nodes = buildGraph(
|
|
98
|
+
resolveConfig({ projects: 25, dependency: { strategy } }),
|
|
99
|
+
);
|
|
100
|
+
for (const node of nodes) {
|
|
101
|
+
for (const dep of node.dependencies) {
|
|
102
|
+
expect(dep).toBeLessThan(node.index);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("random is deterministic for a fixed seed", () => {
|
|
109
|
+
const mk = () =>
|
|
110
|
+
buildGraph(
|
|
111
|
+
resolveConfig({
|
|
112
|
+
projects: 30,
|
|
113
|
+
seed: 7,
|
|
114
|
+
dependency: { strategy: "random", edgeProbability: 0.4 },
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
expect(mk()).toEqual(mk());
|
|
118
|
+
});
|
|
119
|
+
});
|
package/src/graph.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { HarnessConfig } from "./config";
|
|
2
|
+
|
|
3
|
+
/** A single generated project and the upstream projects it depends on. */
|
|
4
|
+
export interface ProjectNode {
|
|
5
|
+
/** Zero-based index in the generated set. */
|
|
6
|
+
index: number;
|
|
7
|
+
/** Package name, e.g. `bench-p0007`. */
|
|
8
|
+
name: string;
|
|
9
|
+
/** Workspace-relative POSIX directory, e.g. `packages/bench-p0007`. */
|
|
10
|
+
dir: string;
|
|
11
|
+
/** Indices of upstream projects this project depends on. */
|
|
12
|
+
dependencies: number[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Deterministic PRNG (mulberry32) so a given seed always yields the same graph.
|
|
17
|
+
*/
|
|
18
|
+
function makeRng(seed: number): () => number {
|
|
19
|
+
let a = seed >>> 0;
|
|
20
|
+
return () => {
|
|
21
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
22
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
23
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
24
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function padWidth(count: number): number {
|
|
29
|
+
return Math.max(4, String(count - 1).length);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function projectName(config: HarnessConfig, index: number): string {
|
|
33
|
+
const width = padWidth(config.projects);
|
|
34
|
+
return `${config.projectPrefix}${String(index).padStart(width, "0")}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Evenly sample up to `count` indices from the inclusive range [start, end]. */
|
|
38
|
+
function evenlySample(start: number, end: number, count: number): number[] {
|
|
39
|
+
const size = end - start + 1;
|
|
40
|
+
if (size <= 0 || count <= 0) return [];
|
|
41
|
+
if (count >= size) {
|
|
42
|
+
return Array.from({ length: size }, (_, i) => start + i);
|
|
43
|
+
}
|
|
44
|
+
const picked: number[] = [];
|
|
45
|
+
for (let i = 0; i < count; i++) {
|
|
46
|
+
// Spread picks across the range deterministically.
|
|
47
|
+
const offset = Math.round((i * (size - 1)) / (count - 1 || 1));
|
|
48
|
+
picked.push(start + offset);
|
|
49
|
+
}
|
|
50
|
+
return [...new Set(picked)];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function computeDependencies(
|
|
54
|
+
config: HarnessConfig,
|
|
55
|
+
index: number,
|
|
56
|
+
rng: () => number,
|
|
57
|
+
): number[] {
|
|
58
|
+
const { strategy, layers, fanout, edgeProbability } = config.dependency;
|
|
59
|
+
|
|
60
|
+
switch (strategy) {
|
|
61
|
+
case "isolated":
|
|
62
|
+
return [];
|
|
63
|
+
|
|
64
|
+
case "chain":
|
|
65
|
+
return index > 0 ? [index - 1] : [];
|
|
66
|
+
|
|
67
|
+
case "fan-out":
|
|
68
|
+
// Every project (except the root) depends on the single root project.
|
|
69
|
+
return index > 0 ? [0] : [];
|
|
70
|
+
|
|
71
|
+
case "layered": {
|
|
72
|
+
const perLayer = Math.ceil(config.projects / layers);
|
|
73
|
+
const layer = Math.floor(index / perLayer);
|
|
74
|
+
if (layer === 0) return [];
|
|
75
|
+
const prevStart = (layer - 1) * perLayer;
|
|
76
|
+
const prevEnd = Math.min(layer * perLayer, config.projects) - 1;
|
|
77
|
+
return evenlySample(prevStart, prevEnd, fanout);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
case "random": {
|
|
81
|
+
if (index === 0) return [];
|
|
82
|
+
const deps: number[] = [];
|
|
83
|
+
for (let j = 0; j < index; j++) {
|
|
84
|
+
if (rng() < edgeProbability) deps.push(j);
|
|
85
|
+
}
|
|
86
|
+
if (fanout > 0 && deps.length > fanout) {
|
|
87
|
+
// Keep the `fanout` closest ancestors for a shallower graph.
|
|
88
|
+
return deps.slice(deps.length - fanout);
|
|
89
|
+
}
|
|
90
|
+
return deps;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
default:
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build the full project graph for a config. Dependencies always point to
|
|
100
|
+
* lower indices, guaranteeing an acyclic graph.
|
|
101
|
+
*/
|
|
102
|
+
export function buildGraph(config: HarnessConfig): ProjectNode[] {
|
|
103
|
+
const rng = makeRng(config.seed);
|
|
104
|
+
const nodes: ProjectNode[] = [];
|
|
105
|
+
for (let index = 0; index < config.projects; index++) {
|
|
106
|
+
const name = projectName(config, index);
|
|
107
|
+
nodes.push({
|
|
108
|
+
index,
|
|
109
|
+
name,
|
|
110
|
+
dir: `packages/${name}`,
|
|
111
|
+
dependencies: computeDependencies(config, index, rng),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return nodes;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The task names generated for every project: `t0`, `t1`, ... */
|
|
118
|
+
export function taskNames(config: HarnessConfig): string[] {
|
|
119
|
+
return Array.from({ length: config.tasksPerProject }, (_, i) => `t${i}`);
|
|
120
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export * from "./bench";
|
|
2
|
+
export { installWorkspace } from "./bench/install";
|
|
3
|
+
export { formatReport } from "./bench/report";
|
|
4
|
+
export type { Stats } from "./bench/stats";
|
|
5
|
+
export { computeStats, formatMs } from "./bench/stats";
|
|
6
|
+
export * from "./config";
|
|
7
|
+
export type { GenerateResult } from "./generate";
|
|
8
|
+
export { generateWorkspace } from "./generate";
|
|
9
|
+
export * from "./graph";
|
|
10
|
+
export type {
|
|
11
|
+
RunSuiteOptions,
|
|
12
|
+
SuiteEvent,
|
|
13
|
+
SuiteResult,
|
|
14
|
+
SuiteScenarioResult,
|
|
15
|
+
} from "./suite";
|
|
16
|
+
export { runSuite } from "./suite";
|
|
17
|
+
export * from "./suite/preset";
|
|
18
|
+
export { formatSuiteMarkdown } from "./suite/report";
|
|
19
|
+
export type {
|
|
20
|
+
GenerationContext,
|
|
21
|
+
RunInvocation,
|
|
22
|
+
ToolAdapter,
|
|
23
|
+
ToolContext,
|
|
24
|
+
WorkspaceWriter,
|
|
25
|
+
} from "./tools";
|
|
26
|
+
export {
|
|
27
|
+
assertSupportedVersion,
|
|
28
|
+
getAdapter,
|
|
29
|
+
getAdapters,
|
|
30
|
+
resolveToolVersions,
|
|
31
|
+
} from "./tools";
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { version } from "../../package.json";
|
|
4
|
+
import { type BenchEvent, type BenchmarkResult, runBenchmark } from "../bench";
|
|
5
|
+
import { installWorkspace } from "../bench/install";
|
|
6
|
+
import type { HarnessConfig } from "../config";
|
|
7
|
+
import { generateWorkspace } from "../generate";
|
|
8
|
+
import { type RunOptions, resolveScenario, type SuiteConfig } from "./preset";
|
|
9
|
+
|
|
10
|
+
export interface SuiteScenarioResult {
|
|
11
|
+
name: string;
|
|
12
|
+
description?: string | undefined;
|
|
13
|
+
config: HarnessConfig;
|
|
14
|
+
run: RunOptions;
|
|
15
|
+
result: BenchmarkResult;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SuiteResult {
|
|
19
|
+
name: string;
|
|
20
|
+
description?: string | undefined;
|
|
21
|
+
generatedAt: string;
|
|
22
|
+
scenarios: SuiteScenarioResult[];
|
|
23
|
+
taskBenchVersion: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SuiteEvent =
|
|
27
|
+
| { kind: "scenario-start"; name: string; index: number; total: number }
|
|
28
|
+
| { kind: "scenario-done"; name: string; index: number; total: number }
|
|
29
|
+
| { kind: "bench"; name: string; event: BenchEvent };
|
|
30
|
+
|
|
31
|
+
export interface RunSuiteOptions {
|
|
32
|
+
/** Base directory under which each scenario workspace is generated. */
|
|
33
|
+
workdir: string;
|
|
34
|
+
/** Run `bun install` in each generated workspace (default true). */
|
|
35
|
+
install?: boolean | undefined;
|
|
36
|
+
/** Keep generated workspaces on disk instead of removing them (default false). */
|
|
37
|
+
keep?: boolean | undefined;
|
|
38
|
+
/** Global run-option overrides applied to every scenario. */
|
|
39
|
+
overrides?: Partial<RunOptions> | undefined;
|
|
40
|
+
onEvent?: ((event: SuiteEvent) => void) | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Run every scenario in a suite: generate a workspace, install, benchmark, and
|
|
45
|
+
* collect the results. Workspaces are removed afterwards unless `keep` is set.
|
|
46
|
+
*/
|
|
47
|
+
export async function runSuite(
|
|
48
|
+
suite: SuiteConfig,
|
|
49
|
+
options: RunSuiteOptions,
|
|
50
|
+
): Promise<SuiteResult> {
|
|
51
|
+
const emit = options.onEvent ?? (() => {});
|
|
52
|
+
const scenarios: SuiteScenarioResult[] = [];
|
|
53
|
+
const total = suite.scenarios.length;
|
|
54
|
+
|
|
55
|
+
for (let index = 0; index < total; index++) {
|
|
56
|
+
const scenario = suite.scenarios[index];
|
|
57
|
+
if (!scenario) continue;
|
|
58
|
+
const resolved = resolveScenario(suite, scenario);
|
|
59
|
+
const { config, run } = resolved;
|
|
60
|
+
|
|
61
|
+
// Global overrides win over per-scenario/defaults.
|
|
62
|
+
const tools = options.overrides?.tools ?? run.tools ?? config.tools;
|
|
63
|
+
// Keep generation and execution tool sets consistent.
|
|
64
|
+
config.tools = tools;
|
|
65
|
+
|
|
66
|
+
const dir = join(options.workdir, resolved.name);
|
|
67
|
+
|
|
68
|
+
emit({
|
|
69
|
+
kind: "scenario-start",
|
|
70
|
+
name: resolved.name,
|
|
71
|
+
index,
|
|
72
|
+
total,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await generateWorkspace(dir, config);
|
|
76
|
+
if (options.install !== false) {
|
|
77
|
+
await installWorkspace(dir, { quiet: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const result = await runBenchmark(dir, {
|
|
81
|
+
tools,
|
|
82
|
+
task: options.overrides?.task ?? run.task,
|
|
83
|
+
coldRuns: options.overrides?.coldRuns ?? run.coldRuns,
|
|
84
|
+
warmRuns: options.overrides?.warmRuns ?? run.warmRuns,
|
|
85
|
+
concurrency: options.overrides?.concurrency ?? run.concurrency,
|
|
86
|
+
daemon: options.overrides?.daemon ?? run.daemon,
|
|
87
|
+
onEvent: (event) =>
|
|
88
|
+
emit({ kind: "bench", name: resolved.name, event }),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
scenarios.push({
|
|
92
|
+
name: resolved.name,
|
|
93
|
+
description: resolved.description,
|
|
94
|
+
config,
|
|
95
|
+
run,
|
|
96
|
+
result,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!options.keep) {
|
|
100
|
+
await rm(dir, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
emit({ kind: "scenario-done", name: resolved.name, index, total });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
name: suite.name,
|
|
108
|
+
description: suite.description,
|
|
109
|
+
generatedAt: new Date().toISOString(),
|
|
110
|
+
scenarios,
|
|
111
|
+
taskBenchVersion: version,
|
|
112
|
+
};
|
|
113
|
+
}
|