fm-bench 0.6.2 → 0.7.0
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 +88 -68
- package/bin/fm-bench.js +14 -0
- package/docs/compatibility.md +46 -0
- package/docs/methodology.md +46 -20
- package/docs/releasing.md +34 -8
- package/docs/report-format.md +79 -2
- package/docs/supported-platforms.md +43 -0
- package/package.json +11 -6
- package/src/bench.js +129 -37
- package/src/capabilities.js +196 -0
- package/src/cli.js +192 -76
- package/src/compare.js +11 -3
- package/src/fm-help.js +131 -0
- package/src/fm.js +110 -106
- package/src/history.js +2 -1
- package/src/macos.js +72 -0
- package/src/metrics.js +212 -0
- package/src/process.js +39 -9
- package/src/prompts.js +22 -4
- package/src/report.js +13 -2
- package/src/schema.js +17 -1
- package/src/stats.js +43 -14
- package/src/table.js +124 -50
package/src/compare.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stripAnsi } from './ansi.js';
|
|
1
2
|
import { compareCompatibility } from './schema.js';
|
|
2
3
|
import { formatMs, formatNumber, formatPercent } from './table.js';
|
|
3
4
|
|
|
@@ -266,12 +267,19 @@ function applyTone(text, tone) {
|
|
|
266
267
|
}
|
|
267
268
|
|
|
268
269
|
function pad(text, width) {
|
|
269
|
-
const
|
|
270
|
-
|
|
270
|
+
const str = safeCell(text);
|
|
271
|
+
const len = str.length;
|
|
272
|
+
return str + ' '.repeat(Math.max(0, width - len));
|
|
271
273
|
}
|
|
272
274
|
|
|
273
275
|
function fitCell(text, width) {
|
|
274
|
-
const str =
|
|
276
|
+
const str = safeCell(text);
|
|
275
277
|
if (str.length <= width) return str + ' '.repeat(width - str.length);
|
|
276
278
|
return `${str.slice(0, width - 1)}…`;
|
|
277
279
|
}
|
|
280
|
+
|
|
281
|
+
// Values can originate from report files that fm-bench did not write, so strip
|
|
282
|
+
// ANSI escapes and control characters before they reach the terminal.
|
|
283
|
+
function safeCell(text) {
|
|
284
|
+
return stripAnsi(String(text ?? '')).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
|
|
285
|
+
}
|
package/src/fm-help.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Pure parsers for `fm` help and status text.
|
|
2
|
+
//
|
|
3
|
+
// These functions take raw text and return plain data so they can be unit
|
|
4
|
+
// tested without spawning a process, and so the capability layer, the model
|
|
5
|
+
// discovery path, and the availability path agree on how `fm` output is read.
|
|
6
|
+
|
|
7
|
+
import { stripAnsi } from './ansi.js';
|
|
8
|
+
|
|
9
|
+
const SECTION_HEADER = /^\s*[A-Z][A-Z0-9 /-]+\s*$/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extract models from the MODELS section of `fm --help`, falling back to a
|
|
13
|
+
* `--model` option list such as `Model to use (system, pcc)`.
|
|
14
|
+
* @param {string} helpText
|
|
15
|
+
* @returns {{ name: string, description: string }[]}
|
|
16
|
+
*/
|
|
17
|
+
export function parseModelsFromHelp(helpText = '') {
|
|
18
|
+
const lines = stripAnsi(helpText).split(/\r?\n/);
|
|
19
|
+
const models = new Map();
|
|
20
|
+
let inModels = false;
|
|
21
|
+
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
if (/^\s*MODELS\s*$/.test(line)) {
|
|
24
|
+
inModels = true;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (inModels && SECTION_HEADER.test(line)) {
|
|
29
|
+
inModels = false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (inModels) {
|
|
33
|
+
const match = line.match(/^\s*([A-Za-z0-9._:-]+)\s{2,}(.+?)\s*$/);
|
|
34
|
+
if (match) {
|
|
35
|
+
const name = match[1];
|
|
36
|
+
const description = match[2].replace(/\s*\(default\)\s*$/, '').trim();
|
|
37
|
+
const existing = models.get(name);
|
|
38
|
+
// The MODELS section is authoritative over a bare option list.
|
|
39
|
+
if (existing) {
|
|
40
|
+
if (!existing.description) existing.description = description;
|
|
41
|
+
} else {
|
|
42
|
+
models.set(name, { name, description });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (/(^|\s)--model\b/.test(line)) {
|
|
48
|
+
const optionMatch = line.match(/\(([^)]*)\)/);
|
|
49
|
+
if (optionMatch) {
|
|
50
|
+
for (const raw of optionMatch[1].split(',')) {
|
|
51
|
+
const name = raw.trim();
|
|
52
|
+
if (/^[A-Za-z0-9._:-]+$/.test(name) && !models.has(name)) {
|
|
53
|
+
models.set(name, { name, description: '' });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return [...models.values()];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read model names and availability out of `fm available` (no model filter).
|
|
65
|
+
* @param {string} output
|
|
66
|
+
* @returns {{ name: string, available: boolean }[]}
|
|
67
|
+
*/
|
|
68
|
+
export function parseAvailabilityList(output = '') {
|
|
69
|
+
const models = new Map();
|
|
70
|
+
for (const line of stripAnsi(output).split(/\r?\n/)) {
|
|
71
|
+
const match = line.match(/^\s*([A-Za-z][A-Za-z0-9._-]*)(?:\s+model)?\s+(?:is\s+)?(available|unavailable|not available)\b/i);
|
|
72
|
+
if (!match) continue;
|
|
73
|
+
const name = match[1].toLowerCase();
|
|
74
|
+
models.set(name, { name, available: /^available$/i.test(match[2]) });
|
|
75
|
+
}
|
|
76
|
+
return [...models.values()];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Interpret `fm available --model <model>` output.
|
|
81
|
+
*
|
|
82
|
+
* `fm` reports the model plus the word "available" on success. Anything with
|
|
83
|
+
* an explicit error/unavailable marker, or a non-zero exit code, counts as
|
|
84
|
+
* unavailable so a partially supported host is never reported as working.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} model
|
|
87
|
+
* @param {string} output combined stdout+stderr
|
|
88
|
+
* @param {number|null} code process exit code
|
|
89
|
+
*/
|
|
90
|
+
export function parseAvailabilityOutput(model, output = '', code = null) {
|
|
91
|
+
const clean = stripAnsi(output).trim();
|
|
92
|
+
const lower = clean.toLowerCase();
|
|
93
|
+
const modelLower = String(model).toLowerCase();
|
|
94
|
+
const hasError = /\berror:|\bunavailable\b|\bnot available\b|\bnot supported\b|\bis invalid for\b/.test(lower);
|
|
95
|
+
const modelPattern = escapeRegExp(modelLower);
|
|
96
|
+
const hasAvailable = new RegExp(`\\b${modelPattern}\\b[\\s\\S]{0,80}\\bavailable\\b`).test(lower)
|
|
97
|
+
|| new RegExp(`\\bavailable\\b[\\s\\S]{0,80}\\b${modelPattern}\\b`).test(lower);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
model,
|
|
101
|
+
available: code === 0 && hasAvailable && !hasError,
|
|
102
|
+
raw: clean,
|
|
103
|
+
reason: hasError ? firstLine(clean) : ''
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Collapse `fm` diagnostics into one actionable line. `fm` writes multi-line
|
|
109
|
+
* usage blocks for argument errors; benchmark output only needs the cause.
|
|
110
|
+
* @param {string} text
|
|
111
|
+
*/
|
|
112
|
+
export function firstLine(text = '') {
|
|
113
|
+
const clean = stripAnsi(text).replace(/\s+/g, ' ').trim();
|
|
114
|
+
if (!clean) return '';
|
|
115
|
+
const sentences = clean.split(/(?<=\.)\s+(?=[A-Z])/);
|
|
116
|
+
const head = sentences.find((part) => /error|invalid|unavailable|not supported|failed/i.test(part)) || sentences[0];
|
|
117
|
+
return head.replace(/^Error:\s*/i, '').trim();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* True when an `fm` diagnostic means "this build does not have that model"
|
|
122
|
+
* rather than "the model exists but is currently unusable".
|
|
123
|
+
* @param {string} text
|
|
124
|
+
*/
|
|
125
|
+
export function isUnsupportedModelError(text = '') {
|
|
126
|
+
return /is invalid for '--model|unknown model|no such model|unrecognized model/i.test(stripAnsi(text));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function escapeRegExp(value) {
|
|
130
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
131
|
+
}
|
package/src/fm.js
CHANGED
|
@@ -1,60 +1,16 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
1
|
import os from 'node:os';
|
|
3
2
|
import { stripAnsi } from './ansi.js';
|
|
3
|
+
import { detectFmCapabilities } from './capabilities.js';
|
|
4
|
+
import { firstLine, isUnsupportedModelError, parseAvailabilityOutput } from './fm-help.js';
|
|
4
5
|
import { runProcess } from './process.js';
|
|
5
6
|
import { parseBatteryOutput, parseThermalOutput } from './system.js';
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
{ name: 'system', description: 'On-device Apple Foundation Model' },
|
|
9
|
-
{ name: 'pcc', description: 'Apple Foundation Model on Private Cloud Compute' }
|
|
10
|
-
];
|
|
8
|
+
export { parseModelsFromHelp, parseAvailabilityOutput } from './fm-help.js';
|
|
11
9
|
|
|
12
10
|
export function fmBinaryFromOptions(options = {}) {
|
|
13
11
|
return options.fmBin || process.env.FM_BIN || 'fm';
|
|
14
12
|
}
|
|
15
13
|
|
|
16
|
-
export function parseModelsFromHelp(helpText) {
|
|
17
|
-
const clean = stripAnsi(helpText);
|
|
18
|
-
const lines = clean.split(/\r?\n/);
|
|
19
|
-
const models = new Map();
|
|
20
|
-
let inModels = false;
|
|
21
|
-
|
|
22
|
-
for (const line of lines) {
|
|
23
|
-
if (/^\s*MODELS\s*$/.test(line)) {
|
|
24
|
-
inModels = true;
|
|
25
|
-
continue;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (inModels && /^\s*[A-Z][A-Z -]+\s*$/.test(line) && !/^\s*MODELS\s*$/.test(line)) {
|
|
29
|
-
inModels = false;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (inModels) {
|
|
33
|
-
const match = line.match(/^\s*([A-Za-z0-9._:-]+)\s{2,}(.+?)\s*$/);
|
|
34
|
-
if (match) {
|
|
35
|
-
models.set(match[1], {
|
|
36
|
-
name: match[1],
|
|
37
|
-
description: match[2].replace(/\s*\(default\)\s*$/, '').trim()
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const optionMatch = /--model\b/.test(line)
|
|
43
|
-
? line.match(/\bmodel\b.*?\(([^)]+)\)/i)
|
|
44
|
-
: null;
|
|
45
|
-
if (optionMatch) {
|
|
46
|
-
for (const raw of optionMatch[1].split(',')) {
|
|
47
|
-
const name = raw.trim();
|
|
48
|
-
if (/^[A-Za-z0-9._:-]+$/.test(name) && !models.has(name)) {
|
|
49
|
-
models.set(name, { name, description: '' });
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return [...models.values()];
|
|
56
|
-
}
|
|
57
|
-
|
|
58
14
|
export async function getFmHelp(fmBin, timeoutMs = 10_000) {
|
|
59
15
|
const result = await runProcess(fmBin, ['--help'], { timeoutMs });
|
|
60
16
|
if (result.error) {
|
|
@@ -69,40 +25,42 @@ export async function getFmHelp(fmBin, timeoutMs = 10_000) {
|
|
|
69
25
|
};
|
|
70
26
|
}
|
|
71
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Discover models, preferring an already-detected capability probe so a run
|
|
30
|
+
* does not spawn `fm --help` more than once.
|
|
31
|
+
* @param {Record<string, any>} options
|
|
32
|
+
*/
|
|
72
33
|
export async function discoverModels(options = {}) {
|
|
73
34
|
const fmBin = fmBinaryFromOptions(options);
|
|
74
|
-
const
|
|
75
|
-
let models = parseModelsFromHelp(help.text);
|
|
76
|
-
|
|
77
|
-
if (models.length === 0 && /Apple Foundation Models CLI/i.test(stripAnsi(help.text))) {
|
|
78
|
-
models = DEFAULT_MODELS;
|
|
79
|
-
}
|
|
80
|
-
|
|
35
|
+
const capabilities = options.capabilities ?? await detectFmCapabilities(fmBin, options);
|
|
81
36
|
return {
|
|
82
37
|
fmBin,
|
|
83
|
-
models,
|
|
84
|
-
help:
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function parseAvailabilityOutput(model, output, code) {
|
|
89
|
-
const clean = stripAnsi(output).trim();
|
|
90
|
-
const lower = clean.toLowerCase();
|
|
91
|
-
const modelLower = model.toLowerCase();
|
|
92
|
-
const hasError = /\berror:|\bunavailable\b|\bnot available\b|\bnot supported\b/.test(lower);
|
|
93
|
-
const hasAvailable = new RegExp(`\\b${escapeRegExp(modelLower)}\\b[\\s\\S]{0,80}\\bavailable\\b|\\bavailable\\b[\\s\\S]{0,80}\\b${escapeRegExp(modelLower)}\\b`).test(lower)
|
|
94
|
-
|| lower.includes(`${modelLower} model available`)
|
|
95
|
-
|| lower.includes(`${titleCase(modelLower)} model available`.toLowerCase());
|
|
96
|
-
|
|
97
|
-
return {
|
|
98
|
-
model,
|
|
99
|
-
available: code === 0 && hasAvailable && !hasError,
|
|
100
|
-
raw: clean,
|
|
101
|
-
reason: hasError ? clean : ''
|
|
38
|
+
models: capabilities.models,
|
|
39
|
+
help: capabilities.help,
|
|
40
|
+
capabilities
|
|
102
41
|
};
|
|
103
42
|
}
|
|
104
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Check one model against the detected `fm` build.
|
|
46
|
+
*
|
|
47
|
+
* Models the build does not expose are reported as unsupported without
|
|
48
|
+
* spawning `fm` at all, so a raw argument-error blob from `fm` can never end
|
|
49
|
+
* up in a report or in `fm-bench models` output.
|
|
50
|
+
*/
|
|
105
51
|
export async function checkModelAvailability(fmBin, model, options = {}) {
|
|
52
|
+
const capabilities = options.capabilities;
|
|
53
|
+
const known = capabilities?.models?.map((entry) => entry.name) ?? [];
|
|
54
|
+
if (capabilities && known.length > 0 && !known.includes(model)) {
|
|
55
|
+
return {
|
|
56
|
+
model,
|
|
57
|
+
available: false,
|
|
58
|
+
unsupported: true,
|
|
59
|
+
raw: '',
|
|
60
|
+
reason: `not supported by this fm build (supported: ${known.join(', ')})`
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
106
64
|
const result = await runProcess(fmBin, ['available', '--model', model], {
|
|
107
65
|
timeoutMs: options.timeoutMs ?? 15_000
|
|
108
66
|
});
|
|
@@ -111,53 +69,99 @@ export async function checkModelAvailability(fmBin, model, options = {}) {
|
|
|
111
69
|
if (result.error) {
|
|
112
70
|
parsed.available = false;
|
|
113
71
|
parsed.reason = result.stderr || result.error.message;
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
if (isUnsupportedModelError(output)) {
|
|
75
|
+
parsed.available = false;
|
|
76
|
+
parsed.unsupported = true;
|
|
77
|
+
const supported = known.length > 0 ? ` (supported: ${known.join(', ')})` : '';
|
|
78
|
+
parsed.reason = `not supported by this fm build${supported}`;
|
|
114
79
|
}
|
|
115
80
|
return parsed;
|
|
116
81
|
}
|
|
117
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Query quota information when the build exposes it.
|
|
85
|
+
* @returns {Promise<{ model: string, supported: boolean, ok: boolean, raw: string, reason: string }>}
|
|
86
|
+
*/
|
|
118
87
|
export async function getQuotaUsage(fmBin, model, options = {}) {
|
|
88
|
+
const features = options.capabilities?.features;
|
|
89
|
+
if (features && !features.quota) {
|
|
90
|
+
return {
|
|
91
|
+
model,
|
|
92
|
+
supported: false,
|
|
93
|
+
ok: false,
|
|
94
|
+
raw: '',
|
|
95
|
+
reason: 'unavailable: this fm build exposes no quota command'
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
119
99
|
const result = await runProcess(fmBin, ['quota-usage', '--model', model], {
|
|
120
100
|
timeoutMs: options.timeoutMs ?? 15_000
|
|
121
101
|
});
|
|
122
102
|
const output = stripAnsi(`${result.stdout}${result.stderr}`).trim();
|
|
123
103
|
return {
|
|
124
104
|
model,
|
|
105
|
+
supported: true,
|
|
125
106
|
ok: result.code === 0,
|
|
126
107
|
raw: output,
|
|
127
|
-
|
|
108
|
+
reason: result.code === 0 ? '' : firstLine(output)
|
|
128
109
|
};
|
|
129
110
|
}
|
|
130
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Count tokens with whichever token-counting command this `fm` build exposes.
|
|
114
|
+
* Returns `ok: false` with a reason when the build cannot count tokens; it
|
|
115
|
+
* never invents a count.
|
|
116
|
+
*/
|
|
131
117
|
export async function countTokens(fmBin, text, options = {}) {
|
|
132
|
-
const
|
|
118
|
+
const command = options.capabilities?.features?.tokenCountCommand ?? 'count-tokens';
|
|
119
|
+
const supported = options.capabilities?.features?.tokenCounting ?? true;
|
|
120
|
+
if (!supported || !command) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
count: null,
|
|
124
|
+
unsupported: true,
|
|
125
|
+
raw: '',
|
|
126
|
+
reason: 'token counting is unavailable in this fm build'
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const result = await runProcess(fmBin, [command, '--quiet'], {
|
|
133
131
|
input: text,
|
|
134
132
|
timeoutMs: options.timeoutMs ?? 15_000
|
|
135
133
|
});
|
|
136
134
|
const output = stripAnsi(`${result.stdout}${result.stderr}`).trim();
|
|
137
|
-
const match = output.match(
|
|
138
|
-
if (result.code !== 0 || !match) {
|
|
135
|
+
const match = output.match(/\d+/);
|
|
136
|
+
if (result.error || result.code !== 0 || !match) {
|
|
139
137
|
return {
|
|
140
138
|
ok: false,
|
|
141
139
|
count: null,
|
|
142
|
-
raw: output
|
|
140
|
+
raw: output,
|
|
141
|
+
reason: result.error?.message || firstLine(output) || `fm ${command} exited with code ${result.code}`
|
|
143
142
|
};
|
|
144
143
|
}
|
|
145
144
|
return {
|
|
146
145
|
ok: true,
|
|
147
146
|
count: Number.parseInt(match[0], 10),
|
|
148
|
-
raw: output
|
|
147
|
+
raw: output,
|
|
148
|
+
reason: ''
|
|
149
149
|
};
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
export async function respond(fmBin, model, prompt, options = {}) {
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (
|
|
160
|
-
if (
|
|
153
|
+
const features = options.capabilities?.features;
|
|
154
|
+
const streamControl = features ? features.streaming : true;
|
|
155
|
+
const modelSelection = features ? features.modelSelection : true;
|
|
156
|
+
const streamed = streamControl && options.stream !== false;
|
|
157
|
+
|
|
158
|
+
const args = ['respond'];
|
|
159
|
+
if (modelSelection) args.push('--model', model);
|
|
160
|
+
if (streamControl && !streamed) args.push('--no-stream');
|
|
161
|
+
if (options.greedy && (features?.greedy ?? true)) args.push('--greedy');
|
|
162
|
+
if (options.instructions && (features?.instructions ?? true)) args.push('--instructions', options.instructions);
|
|
163
|
+
if (options.useCase && (features?.useCase ?? true)) args.push('--use-case', options.useCase);
|
|
164
|
+
if (options.guardrails && (features?.guardrails ?? true)) args.push('--guardrails', options.guardrails);
|
|
161
165
|
|
|
162
166
|
const result = await runProcess(fmBin, args, {
|
|
163
167
|
input: prompt,
|
|
@@ -166,8 +170,10 @@ export async function respond(fmBin, model, prompt, options = {}) {
|
|
|
166
170
|
|
|
167
171
|
const output = stripAnsi(result.stdout).trim();
|
|
168
172
|
const errorText = stripAnsi(result.stderr).trim();
|
|
173
|
+
const failed = result.code !== 0 || result.timedOut;
|
|
174
|
+
|
|
169
175
|
return {
|
|
170
|
-
ok:
|
|
176
|
+
ok: !failed,
|
|
171
177
|
model,
|
|
172
178
|
prompt,
|
|
173
179
|
output,
|
|
@@ -179,11 +185,17 @@ export async function respond(fmBin, model, prompt, options = {}) {
|
|
|
179
185
|
firstOutputMs: streamed ? result.firstStdoutMs : null,
|
|
180
186
|
streamed,
|
|
181
187
|
stdoutChunks: result.stdoutChunks,
|
|
182
|
-
stdoutChunkTimesMs: streamed ? result.stdoutChunkTimesMs : []
|
|
188
|
+
stdoutChunkTimesMs: streamed ? result.stdoutChunkTimesMs : [],
|
|
189
|
+
error: failed
|
|
190
|
+
? (result.timedOut
|
|
191
|
+
? `timed out after ${options.timeoutMs ?? 60_000}ms`
|
|
192
|
+
: firstLine(errorText) || `fm exited with code ${result.code ?? result.signal}`)
|
|
193
|
+
: ''
|
|
183
194
|
};
|
|
184
195
|
}
|
|
185
196
|
|
|
186
|
-
export async function collectEnvironment(fmBin) {
|
|
197
|
+
export async function collectEnvironment(fmBin, options = {}) {
|
|
198
|
+
const capabilities = options.capabilities;
|
|
187
199
|
const swVers = await runProcess('sw_vers', [], { timeoutMs: 5_000 });
|
|
188
200
|
const macOS = stripAnsi(swVers.stdout).trim() || null;
|
|
189
201
|
|
|
@@ -196,15 +208,15 @@ export async function collectEnvironment(fmBin) {
|
|
|
196
208
|
const batteryResult = await runProcess('pmset', ['-g', 'batt'], { timeoutMs: 5_000 });
|
|
197
209
|
const battery = parseBatteryOutput(`${batteryResult.stdout || ''}${batteryResult.stderr || ''}`);
|
|
198
210
|
|
|
199
|
-
let fmHelpDigest = null;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
fmHelpDigest =
|
|
211
|
+
let fmHelpDigest = capabilities?.digest ?? null;
|
|
212
|
+
if (fmHelpDigest == null) {
|
|
213
|
+
try {
|
|
214
|
+
const help = await getFmHelp(fmBin, 10_000);
|
|
215
|
+
const detected = await detectFmCapabilities(fmBin, { help: { text: help.text } });
|
|
216
|
+
fmHelpDigest = detected.digest;
|
|
217
|
+
} catch {
|
|
218
|
+
fmHelpDigest = null;
|
|
205
219
|
}
|
|
206
|
-
} catch {
|
|
207
|
-
fmHelpDigest = null;
|
|
208
220
|
}
|
|
209
221
|
|
|
210
222
|
const memRaw = (memBytes.stdout || '').trim();
|
|
@@ -237,11 +249,3 @@ export async function collectEnvironment(fmBin) {
|
|
|
237
249
|
: null
|
|
238
250
|
};
|
|
239
251
|
}
|
|
240
|
-
|
|
241
|
-
function escapeRegExp(value) {
|
|
242
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function titleCase(value) {
|
|
246
|
-
return value.slice(0, 1).toUpperCase() + value.slice(1);
|
|
247
|
-
}
|
package/src/history.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { stripAnsi } from './ansi.js';
|
|
3
4
|
import { formatMs, formatNumber, formatPercent } from './table.js';
|
|
4
5
|
|
|
5
6
|
export async function loadHistory(dir) {
|
|
@@ -100,7 +101,7 @@ export function renderHistoryReport(reports, options = {}) {
|
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
function fit(text, width) {
|
|
103
|
-
const str = String(text ?? '');
|
|
104
|
+
const str = stripAnsi(String(text ?? '')).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
|
|
104
105
|
if (str.length <= width) return str + ' '.repeat(width - str.length);
|
|
105
106
|
return `${str.slice(0, width - 1)}…`;
|
|
106
107
|
}
|
package/src/macos.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { runProcess } from './process.js';
|
|
2
|
+
|
|
3
|
+
// macOS releases that ship Apple's `fm` CLI. Older macOS versions cannot run
|
|
4
|
+
// fm-bench at all because the benchmarked binary does not exist there.
|
|
5
|
+
export const SUPPORTED_MACOS_MAJOR = 27;
|
|
6
|
+
|
|
7
|
+
export const MIN_SUPPORTED_MACOS = `${SUPPORTED_MACOS_MAJOR}.0`;
|
|
8
|
+
|
|
9
|
+
export const MACOS_REQUIREMENT_MESSAGE =
|
|
10
|
+
`fm-bench requires macOS ${SUPPORTED_MACOS_MAJOR} or newer (Apple's fm CLI is preinstalled there).`;
|
|
11
|
+
|
|
12
|
+
export function parseMacosVersion(text = '') {
|
|
13
|
+
const match = String(text).match(/ProductVersion:\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
14
|
+
if (!match) return null;
|
|
15
|
+
return {
|
|
16
|
+
major: Number.parseInt(match[1], 10),
|
|
17
|
+
minor: match[2] != null ? Number.parseInt(match[2], 10) : 0,
|
|
18
|
+
patch: match[3] != null ? Number.parseInt(match[3], 10) : 0,
|
|
19
|
+
version: [match[1], match[2], match[3]].filter((part) => part != null).join('.')
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isMacosSupported(parsed) {
|
|
24
|
+
return parsed != null && Number.isInteger(parsed.major) && parsed.major >= SUPPORTED_MACOS_MAJOR;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function evaluateMacosSupport(platform, parsed) {
|
|
28
|
+
if (platform !== 'darwin') {
|
|
29
|
+
return {
|
|
30
|
+
supported: false,
|
|
31
|
+
reason: `fm-bench only runs on macOS with Apple's fm CLI (detected platform: ${platform}).`,
|
|
32
|
+
latestSupported: `macOS ${MIN_SUPPORTED_MACOS} or newer`
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (parsed == null) {
|
|
36
|
+
return {
|
|
37
|
+
supported: true,
|
|
38
|
+
warnOnly: true,
|
|
39
|
+
reason: 'could not determine the macOS version',
|
|
40
|
+
latestSupported: `macOS ${MIN_SUPPORTED_MACOS} or newer`
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (isMacosSupported(parsed)) {
|
|
44
|
+
return {
|
|
45
|
+
supported: true,
|
|
46
|
+
reason: null,
|
|
47
|
+
latestSupported: `macOS ${MIN_SUPPORTED_MACOS} or newer`
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
supported: false,
|
|
52
|
+
reason: `detected macOS ${parsed.version}, but ${MACOS_REQUIREMENT_MESSAGE}`,
|
|
53
|
+
latestSupported: `macOS ${MIN_SUPPORTED_MACOS} or newer`
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function formatMacosRequirementError({ reason, latestSupported }) {
|
|
58
|
+
return [
|
|
59
|
+
`unsupported macOS: ${reason}`,
|
|
60
|
+
`Latest supported: ${latestSupported} (fm is not available on older macOS releases).`,
|
|
61
|
+
'Pass --fm-bin <path> (or set FM_BIN) to benchmark an fm binary you provide on this host.'
|
|
62
|
+
].join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function detectMacosVersion(options = {}) {
|
|
66
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
67
|
+
if (typeof options.swVers === 'function') {
|
|
68
|
+
return options.swVers();
|
|
69
|
+
}
|
|
70
|
+
const result = await runProcess('sw_vers', [], { timeoutMs });
|
|
71
|
+
return result.stdout || result.stderr;
|
|
72
|
+
}
|