@ticket-gate/react 0.0.0-stage → 0.1.0-rc.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/LICENSE +202 -0
- package/README.md +88 -2
- package/compiler.mjs +391 -0
- package/index.mjs +27 -0
- package/package.json +19 -4
- package/profile.mjs +27 -0
- package/profiler/capture.mjs +36 -0
- package/profiler/cli.mjs +206 -0
- package/profiler/nightly-child.mjs +26 -0
- package/profiler/nightly.mjs +202 -0
- package/profiler/stats.mjs +130 -0
- package/profiler/trace.mjs +85 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { profile } from './profile.mjs';
|
|
2
|
+
|
|
3
|
+
/** The react pack. `profile` is a function returning the profile config, loaded only when the command runs. */
|
|
4
|
+
export function reactPack({ profile: load } = {}) {
|
|
5
|
+
if (load !== undefined && typeof load !== 'function')
|
|
6
|
+
throw new TypeError(
|
|
7
|
+
'reactPack: profile must be a function returning the profile config',
|
|
8
|
+
);
|
|
9
|
+
return {
|
|
10
|
+
name: 'react',
|
|
11
|
+
version: '0.0.0-local',
|
|
12
|
+
packCommands:
|
|
13
|
+
load === undefined
|
|
14
|
+
? {}
|
|
15
|
+
: {
|
|
16
|
+
profile: async (args) => {
|
|
17
|
+
try {
|
|
18
|
+
await profile(await load()).run(args);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
console.error(err);
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
return process.exitCode ?? 0;
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ticket-gate/react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-rc.1",
|
|
4
|
+
"description": "The React Compiler bailout ratchet for @ticket-gate/engine.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": { "node": ">=22" },
|
|
8
|
+
"exports": {
|
|
9
|
+
"./*": "./*",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"imports": { "#engine/*": "@ticket-gate/engine/*" },
|
|
13
|
+
"dependencies": { "@ticket-gate/engine": "0.1.0-rc.1" },
|
|
14
|
+
"files": ["*", "!test"],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/dmitry-prohorov/ticket-gate.git",
|
|
18
|
+
"directory": "packages/react"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": { "access": "public", "provenance": true }
|
|
21
|
+
}
|
package/profile.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { profiler } from './profiler/cli.mjs';
|
|
2
|
+
|
|
3
|
+
const BUILD_TEXT = ['command', 'unmangle', 'start', 'noBuildLabel'];
|
|
4
|
+
|
|
5
|
+
/** Binds the profiler to one project's config; see the README. */
|
|
6
|
+
export function profile(cfg) {
|
|
7
|
+
if (cfg.lock !== false && (typeof cfg.lock !== 'string' || cfg.lock === ''))
|
|
8
|
+
throw new TypeError('profile: lock must be a stack lock owner or false');
|
|
9
|
+
if (
|
|
10
|
+
BUILD_TEXT.some((k) => typeof cfg.build?.[k] !== 'string') ||
|
|
11
|
+
!Number.isInteger(cfg.build?.port)
|
|
12
|
+
)
|
|
13
|
+
throw new TypeError(
|
|
14
|
+
`profile: build needs ${BUILD_TEXT.join(', ')} and an integer port`,
|
|
15
|
+
);
|
|
16
|
+
return profiler(cfg);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A Vite app aliases react-dom/client to react-dom/profiling and sets esbuild.keepNames itself; a wrong build is caught by the trace guards. */
|
|
20
|
+
export const viteProfiling = ({ cmd, preview }) => ({
|
|
21
|
+
command: cmd,
|
|
22
|
+
unmangle: 'esbuild.keepNames in the Vite config',
|
|
23
|
+
start: preview,
|
|
24
|
+
port: 4173,
|
|
25
|
+
noBuildLabel: 'unknown (existing server on :4173)',
|
|
26
|
+
});
|
|
27
|
+
export { reactProfile } from './profiler/nightly.mjs';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Browser capture for the React profiler: context, DevTools hook stub, trace.
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
|
|
4
|
+
export const TRACE_CATEGORIES = [
|
|
5
|
+
'devtools.timeline',
|
|
6
|
+
'disabled-by-default-devtools.timeline',
|
|
7
|
+
'blink.user_timing',
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
/** Loads Playwright's chromium from the package.json that depends on it. */
|
|
11
|
+
export function resolveChromium(playwrightFrom) {
|
|
12
|
+
const require = createRequire(playwrightFrom);
|
|
13
|
+
return require('@playwright/test').chromium;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A fresh context with the stub hook, and its first page. The caller signs in. */
|
|
17
|
+
export async function openContext(browser, context) {
|
|
18
|
+
const ctx = await browser.newContext(context);
|
|
19
|
+
// React sets ProfileMode on the root, and so writes the Components track, only when a
|
|
20
|
+
// DevTools hook exists. A disabled stub turns that on without injecting a backend.
|
|
21
|
+
await ctx.addInitScript(() => {
|
|
22
|
+
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { isDisabled: true };
|
|
23
|
+
});
|
|
24
|
+
const page = await ctx.newPage();
|
|
25
|
+
return { context: ctx, page };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Traces `act` into `file`. */
|
|
29
|
+
export async function captureRun(browser, page, act, file) {
|
|
30
|
+
await browser.startTracing(page, {
|
|
31
|
+
path: file,
|
|
32
|
+
categories: TRACE_CATEGORIES,
|
|
33
|
+
});
|
|
34
|
+
await act();
|
|
35
|
+
await browser.stopTracing();
|
|
36
|
+
}
|
package/profiler/cli.mjs
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// The profiler run loop and its binding to one project config. Every project
|
|
2
|
+
// value (scenarios, urls, build command, login, reset) arrives in `cfg`; see the shim
|
|
3
|
+
// that calls `profiler(cfg)` for the shape.
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { takeOrExit } from '#engine/lock.mjs';
|
|
8
|
+
import { serve } from '#engine/serve.mjs';
|
|
9
|
+
import { captureRun, openContext, resolveChromium } from './capture.mjs';
|
|
10
|
+
import {
|
|
11
|
+
SETUP_MISSING,
|
|
12
|
+
applyBudget,
|
|
13
|
+
budgetFailures,
|
|
14
|
+
compare,
|
|
15
|
+
parseArgs,
|
|
16
|
+
parseBudgets,
|
|
17
|
+
scenarioRenders,
|
|
18
|
+
setupDiff,
|
|
19
|
+
} from './stats.mjs';
|
|
20
|
+
import {
|
|
21
|
+
analyse,
|
|
22
|
+
median,
|
|
23
|
+
problemHints,
|
|
24
|
+
reduceRun,
|
|
25
|
+
traceProblem,
|
|
26
|
+
} from './trace.mjs';
|
|
27
|
+
|
|
28
|
+
/** Every problem at once, [] when ready. `probe(url)` resolves to a boolean; `hasEnv()` says whether the app env exists. */
|
|
29
|
+
export async function preflight({ build }, cfg, probe, hasEnv) {
|
|
30
|
+
const [appUp, ...up] = await Promise.all([
|
|
31
|
+
probe(cfg.appUrl),
|
|
32
|
+
...cfg.services.map((s) => probe(s.url)),
|
|
33
|
+
]);
|
|
34
|
+
const problems = [];
|
|
35
|
+
if (build && appUp) problems.push(cfg.preflight.portInUse);
|
|
36
|
+
if (!build && !appUp) problems.push(cfg.preflight.noApp);
|
|
37
|
+
cfg.services.forEach((s, i) => {
|
|
38
|
+
if (!up[i]) problems.push(s.problem);
|
|
39
|
+
});
|
|
40
|
+
if (!hasEnv()) problems.push(cfg.preflight.noWebEnv);
|
|
41
|
+
return problems;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function loadBaseline(path) {
|
|
45
|
+
let baseline;
|
|
46
|
+
try {
|
|
47
|
+
baseline = JSON.parse(readFileSync(path, 'utf8'));
|
|
48
|
+
} catch (err) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`--compare ${path}: cannot read it as JSON (${err.message})`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (typeof baseline?.summary !== 'object' || baseline.summary === null)
|
|
54
|
+
throw new Error(
|
|
55
|
+
`--compare ${path}: not a results file (no "summary" object)`,
|
|
56
|
+
);
|
|
57
|
+
return baseline;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function gitOut(cfg, args) {
|
|
61
|
+
return execSync(`git ${args}`, { cwd: cfg.root }).toString().trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function run(cfg, argv) {
|
|
65
|
+
// Refuses at once while another run holds the stack.
|
|
66
|
+
if (cfg.lock !== false) takeOrExit(cfg.lock);
|
|
67
|
+
const hints = problemHints(cfg);
|
|
68
|
+
const opts = parseArgs(argv, cfg);
|
|
69
|
+
const baseline = opts.compare === null ? null : loadBaseline(opts.compare);
|
|
70
|
+
const problems = await preflight(
|
|
71
|
+
{ build: opts.build },
|
|
72
|
+
cfg,
|
|
73
|
+
cfg.probe,
|
|
74
|
+
cfg.hasEnv,
|
|
75
|
+
);
|
|
76
|
+
if (problems.length > 0) throw new Error(problems.join('\n'));
|
|
77
|
+
mkdirSync(join(opts.out, 'traces'), { recursive: true });
|
|
78
|
+
|
|
79
|
+
const results = {};
|
|
80
|
+
let chromiumVersion = '';
|
|
81
|
+
let stopServer = null;
|
|
82
|
+
const onSigint = async () => {
|
|
83
|
+
if (stopServer !== null) await stopServer();
|
|
84
|
+
process.exit(130);
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
if (opts.build) {
|
|
88
|
+
await serve(
|
|
89
|
+
{
|
|
90
|
+
build: cfg.build.command,
|
|
91
|
+
start: cfg.build.start,
|
|
92
|
+
port: cfg.build.port,
|
|
93
|
+
env: cfg.build.env,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
onSpawn: (stop) => {
|
|
97
|
+
stopServer = stop;
|
|
98
|
+
process.once('SIGINT', onSigint);
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const chromium = resolveChromium(cfg.playwrightFrom);
|
|
104
|
+
const browser = await chromium.launch({ handleSIGINT: false });
|
|
105
|
+
chromiumVersion = browser.version();
|
|
106
|
+
try {
|
|
107
|
+
for (const sc of opts.scenarios) {
|
|
108
|
+
results[sc] = [];
|
|
109
|
+
for (let run = 0; run <= opts.runs; run++) {
|
|
110
|
+
if (cfg.writing.includes(sc) || run === 0) await cfg.reset();
|
|
111
|
+
const { context, page } = await openContext(browser, cfg.context);
|
|
112
|
+
await cfg.open(page);
|
|
113
|
+
const act = await cfg.scenarios[sc](page);
|
|
114
|
+
const file = join(
|
|
115
|
+
opts.out,
|
|
116
|
+
'traces',
|
|
117
|
+
`${cfg.tracePrefix}${sc}-run${run}.json`,
|
|
118
|
+
);
|
|
119
|
+
await captureRun(browser, page, act, file);
|
|
120
|
+
await context.close();
|
|
121
|
+
const r = reduceRun(
|
|
122
|
+
JSON.parse(readFileSync(file, 'utf8')),
|
|
123
|
+
cfg.componentsFor(sc),
|
|
124
|
+
hints,
|
|
125
|
+
);
|
|
126
|
+
console.log(
|
|
127
|
+
`${sc} ${run === 0 ? 'warm-up' : `run ${run}`} ${JSON.stringify(r)}`,
|
|
128
|
+
);
|
|
129
|
+
if (run > 0) results[sc].push(r);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} finally {
|
|
133
|
+
await browser.close();
|
|
134
|
+
await cfg.reset();
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
if (stopServer !== null) {
|
|
138
|
+
await stopServer();
|
|
139
|
+
process.removeListener('SIGINT', onSigint);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const summary = {};
|
|
144
|
+
for (const [sc, list] of Object.entries(results))
|
|
145
|
+
summary[sc] = Object.fromEntries(
|
|
146
|
+
Object.keys(list[0]).map((k) => [k, median(list.map((r) => r[k]))]),
|
|
147
|
+
);
|
|
148
|
+
const meta = {
|
|
149
|
+
sha: gitOut(cfg, 'rev-parse HEAD'),
|
|
150
|
+
dirty: gitOut(cfg, 'status --porcelain') !== '',
|
|
151
|
+
buildFlags: opts.build ? cfg.build.command : cfg.build.noBuildLabel,
|
|
152
|
+
chromium: chromiumVersion,
|
|
153
|
+
runs: opts.runs,
|
|
154
|
+
scenarios: opts.scenarios,
|
|
155
|
+
date: new Date().toISOString(),
|
|
156
|
+
};
|
|
157
|
+
const file = join(opts.out, `results-${Date.now()}.json`);
|
|
158
|
+
writeFileSync(file, JSON.stringify({ meta, results, summary }, null, 2));
|
|
159
|
+
console.log(`median of ${opts.runs}:`);
|
|
160
|
+
console.table(summary);
|
|
161
|
+
console.log(`traces and results: ${opts.out}`);
|
|
162
|
+
if (baseline !== null) {
|
|
163
|
+
console.log('this run:', meta);
|
|
164
|
+
console.log('baseline:', baseline.meta ?? '(no setup record)');
|
|
165
|
+
for (const line of setupDiff(meta, baseline.meta))
|
|
166
|
+
console.log(`setup differs: ${line}`);
|
|
167
|
+
console.table(compare(summary, baseline));
|
|
168
|
+
}
|
|
169
|
+
const { lines, code } = applyBudget(
|
|
170
|
+
summary,
|
|
171
|
+
opts.maxRenders,
|
|
172
|
+
cfg.componentsFor,
|
|
173
|
+
);
|
|
174
|
+
for (const line of lines) console.error(line);
|
|
175
|
+
if (code !== 0) process.exitCode = code;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Binds the module-level functions to one config; the shim re-exports these names. */
|
|
179
|
+
export function profiler(cfg) {
|
|
180
|
+
const firstScenario = Object.keys(cfg.scenarios)[0];
|
|
181
|
+
const first = cfg.componentsFor(firstScenario);
|
|
182
|
+
const hints = problemHints(cfg);
|
|
183
|
+
return {
|
|
184
|
+
PROBLEM: hints,
|
|
185
|
+
PREFLIGHT: cfg.preflight,
|
|
186
|
+
SETUP_MISSING,
|
|
187
|
+
analyse: (trace, names = first) => analyse(trace, names),
|
|
188
|
+
traceProblem: (r, names = first) => traceProblem(r, names, hints),
|
|
189
|
+
reduceRun: (trace, scenario = firstScenario) =>
|
|
190
|
+
reduceRun(trace, cfg.componentsFor(scenario), hints),
|
|
191
|
+
median,
|
|
192
|
+
compare,
|
|
193
|
+
setupDiff,
|
|
194
|
+
parseBudgets: (text) => parseBudgets(text, cfg),
|
|
195
|
+
parseArgs: (argv) => parseArgs(argv, cfg),
|
|
196
|
+
budgetFailures: (summary, budgets) =>
|
|
197
|
+
budgetFailures(summary, budgets, cfg.componentsFor),
|
|
198
|
+
applyBudget: (summary, budgets) =>
|
|
199
|
+
applyBudget(summary, budgets, cfg.componentsFor),
|
|
200
|
+
scenarioRenders: (row, scenario) =>
|
|
201
|
+
scenarioRenders(row, scenario, cfg.componentsFor),
|
|
202
|
+
preflight: ({ build }, probe = cfg.probe, hasEnv = cfg.hasEnv) =>
|
|
203
|
+
preflight({ build }, cfg, probe, hasEnv),
|
|
204
|
+
run: (argv) => run(cfg, argv),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Spawned by the nightly suite: one profiler run in the worktree. The nightly already holds the
|
|
2
|
+
// stack lock, so the profiler takes none. It leaves results.json and numbers.json in the run directory.
|
|
3
|
+
import { readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import { profiler } from './cli.mjs';
|
|
8
|
+
import { numbersOf } from './nightly.mjs';
|
|
9
|
+
|
|
10
|
+
const [config, out, ...args] = process.argv.slice(2);
|
|
11
|
+
try {
|
|
12
|
+
const { default: cfg } = await import(pathToFileURL(resolve(config)).href);
|
|
13
|
+
await profiler({ ...cfg, lock: false }).run([...args, `--out=${out}`]);
|
|
14
|
+
const written = readdirSync(out)
|
|
15
|
+
.filter((f) => /^results-\d+\.json$/.test(f))
|
|
16
|
+
.sort()
|
|
17
|
+
.at(-1);
|
|
18
|
+
renameSync(join(out, written), join(out, 'results.json'));
|
|
19
|
+
const { summary } = JSON.parse(readFileSync(join(out, 'results.json'), 'utf8'));
|
|
20
|
+
writeFileSync(join(out, 'numbers.json'), JSON.stringify(numbersOf(summary, cfg)));
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error(String(err.message).replace(/\s*\n\s*/g, ' '));
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
}
|
|
25
|
+
// A handle a project config left open must not hold the suite until its time limit.
|
|
26
|
+
process.exit(process.exitCode ?? 0);
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// The nightly suite runner: it profiles one build in a child process, so the suite's time
|
|
2
|
+
// limit can stop it, and turns the numbers the child leaves in the run directory into a result.
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import { byRanAtDesc, readNightly, suiteView } from '#engine/nightly/record.mjs';
|
|
7
|
+
import { fixSeed, realGit, short } from '#engine/nightly/seed.mjs';
|
|
8
|
+
import { scenarioRenders } from './stats.mjs';
|
|
9
|
+
|
|
10
|
+
export const NIGHTLY_CHILD = fileURLToPath(new URL('./nightly-child.mjs', import.meta.url));
|
|
11
|
+
const STABLE_NIGHTS = 7;
|
|
12
|
+
const GRACE_S = 30;
|
|
13
|
+
const LOG_TAIL = 2000;
|
|
14
|
+
const SHOWN_MERGES = 3;
|
|
15
|
+
const NO_FLAKY = [];
|
|
16
|
+
|
|
17
|
+
/** Medians of each scenario's own component renders, and the scenarios over their budget. */
|
|
18
|
+
export function numbersOf(summary, { budgets = {}, componentsFor }) {
|
|
19
|
+
const medians = {};
|
|
20
|
+
const over = [];
|
|
21
|
+
const advisory = [];
|
|
22
|
+
for (const [scenario, row] of Object.entries(summary)) {
|
|
23
|
+
const renders = scenarioRenders(row, scenario, componentsFor);
|
|
24
|
+
medians[scenario] = renders;
|
|
25
|
+
const b = budgets[scenario];
|
|
26
|
+
if (b === undefined) continue;
|
|
27
|
+
if (typeof b?.max !== 'number') throw new Error(`budget for ${scenario} needs a numeric max`);
|
|
28
|
+
const blocking = b.blocking === true;
|
|
29
|
+
if (!blocking) advisory.push(scenario);
|
|
30
|
+
if (renders > b.max) over.push({ scenario, renders, budget: b.max, blocking });
|
|
31
|
+
}
|
|
32
|
+
return { medians, over, advisory };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Advisory scenarios in budget for the last 7 nights whose medians stayed within 10%. */
|
|
36
|
+
export function promotable(history, suite, tonight) {
|
|
37
|
+
const nights = history
|
|
38
|
+
.map((l) => ({ ranAt: l.ranAt, view: suiteView(l, suite) }))
|
|
39
|
+
.filter((n) => n.view !== null)
|
|
40
|
+
.sort(byRanAtDesc)
|
|
41
|
+
.slice(0, STABLE_NIGHTS - 1);
|
|
42
|
+
if (nights.length < STABLE_NIGHTS - 1) return [];
|
|
43
|
+
const series = [tonight, ...nights.map((n) => n.view.extra)];
|
|
44
|
+
return tonight.advisory.filter((sc) => {
|
|
45
|
+
if (
|
|
46
|
+
!series.every(
|
|
47
|
+
(e) => typeof e?.medians?.[sc] === 'number' && !(e.over ?? []).some((o) => o.scenario === sc),
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
return false;
|
|
51
|
+
const values = series.map((e) => e.medians[sc]);
|
|
52
|
+
return (Math.max(...values) - Math.min(...values)) * 10 <= Math.min(...values);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function suspectsText({ lastGreenSha, merges }) {
|
|
57
|
+
const since = lastGreenSha ? short(lastGreenSha) : 'no green night';
|
|
58
|
+
const shown = merges.slice(0, SHOWN_MERGES).map((m) => `${short(m.sha)} ${m.subject}`);
|
|
59
|
+
const more = merges.length > SHOWN_MERGES ? ` +${merges.length - SHOWN_MERGES} more` : '';
|
|
60
|
+
return `suspect merges since ${since}: ${shown.join(', ') || 'none'}${more}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The one line `nightly status` prints for this suite. */
|
|
64
|
+
export function statusLine(view) {
|
|
65
|
+
const e = view.extra ?? {};
|
|
66
|
+
const parts = [];
|
|
67
|
+
if (e.noNumbers !== undefined)
|
|
68
|
+
parts.push(e.noNumbers === 'cut-short' ? 'cut-short' : `no numbers: ${e.noNumbers}`);
|
|
69
|
+
else if (view.status === 'error') parts.push(view.reason ?? 'error');
|
|
70
|
+
for (const o of e.over ?? [])
|
|
71
|
+
parts.push(`${o.scenario} renders ${o.renders} > ${o.budget}, ${o.blocking ? 'blocking' : 'advisory'}`);
|
|
72
|
+
for (const sc of e.promote ?? [])
|
|
73
|
+
parts.push(`${sc} stable ${STABLE_NIGHTS} of ${STABLE_NIGHTS} nights: promote with blocking: true`);
|
|
74
|
+
if (e.suspects === null) parts.push('suspect merges unavailable');
|
|
75
|
+
else if (e.suspects !== undefined) parts.push(suspectsText(e.suspects));
|
|
76
|
+
const word = view.status === 'error' ? 'red' : view.status;
|
|
77
|
+
return `nightly profile: ${word}${parts.length > 0 ? ` (${parts.join('; ')})` : ''}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const noNumbers = ({ runs, out, reason, suspects }) => ({
|
|
81
|
+
timedOut: false,
|
|
82
|
+
summary: summaryOf({ expected: 0, unexpected: 1, flaky: 0, scenarios: 0, runs, failed: ['no numbers'] }),
|
|
83
|
+
report: { results: null, log: out.slice(-LOG_TAIL) },
|
|
84
|
+
extra: { medians: {}, over: [], advisory: [], noNumbers: reason, suspects },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const summaryOf = ({ expected, unexpected, flaky, scenarios, runs, failed }) => ({
|
|
88
|
+
stats: { expected, unexpected, flaky, skipped: 0, scenarios, runs },
|
|
89
|
+
failedSpecs: failed,
|
|
90
|
+
flakySpecs: NO_FLAKY,
|
|
91
|
+
redTests: [],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
95
|
+
|
|
96
|
+
function readNumbers(io, dir) {
|
|
97
|
+
const file = join(dir, 'numbers.json');
|
|
98
|
+
if (!io.exists(file)) return null;
|
|
99
|
+
try {
|
|
100
|
+
const n = JSON.parse(io.readFile(file));
|
|
101
|
+
const shaped = isPlain(n?.medians) && Array.isArray(n.over) && Array.isArray(n.advisory);
|
|
102
|
+
return shaped && Object.keys(n.medians).length > 0 ? n : null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A runner for `nightly.suites`. The suite entry passes `keep: runner.keep`.
|
|
110
|
+
* `config` is the profile config module, relative to the worktree.
|
|
111
|
+
*/
|
|
112
|
+
export function reactProfile({ config, runs, scenarios, keepTraces }, { git = realGit } = {}) {
|
|
113
|
+
if (typeof config !== 'string' || config === '')
|
|
114
|
+
throw new TypeError('reactProfile: config must be a path relative to the worktree');
|
|
115
|
+
if (!Number.isInteger(runs) || runs < 1)
|
|
116
|
+
throw new TypeError('reactProfile: runs must be a positive integer');
|
|
117
|
+
if (scenarios !== 'all' && !(Array.isArray(scenarios) && scenarios.length > 0))
|
|
118
|
+
throw new TypeError("reactProfile: scenarios must be 'all' or a non-empty list");
|
|
119
|
+
if (!Number.isInteger(keepTraces) || keepTraces < 1)
|
|
120
|
+
throw new TypeError('reactProfile: keepTraces must be a positive integer');
|
|
121
|
+
const flags = ['--build', `--runs=${runs}`, ...(scenarios === 'all' ? [] : [`--scenarios=${scenarios.join(',')}`])];
|
|
122
|
+
return {
|
|
123
|
+
name: 'profile',
|
|
124
|
+
keep: { reports: 'all', traces: keepTraces },
|
|
125
|
+
statusLine,
|
|
126
|
+
async run({ wt, io, args = [], limitS, env = {}, dir }) {
|
|
127
|
+
let history = [];
|
|
128
|
+
const suite = basename(dirname(dir));
|
|
129
|
+
const suspectsOf = () => {
|
|
130
|
+
try {
|
|
131
|
+
const lines = history.flatMap((l) => {
|
|
132
|
+
const v = suiteView(l, suite);
|
|
133
|
+
return v === null ? [] : [{ sha: l.sha, ranAt: l.ranAt, status: v.status }];
|
|
134
|
+
});
|
|
135
|
+
const { lastGreenSha, merges } = fixSeed({
|
|
136
|
+
latest: { sha: basename(dir).slice(0, 12) },
|
|
137
|
+
history: lines,
|
|
138
|
+
git,
|
|
139
|
+
});
|
|
140
|
+
return { lastGreenSha, merges: merges.map(({ sha, subject }) => ({ sha, subject })) };
|
|
141
|
+
} catch {
|
|
142
|
+
return null; // no suspects without git
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
try {
|
|
146
|
+
history = readNightly(dirname(dirname(dir))).history;
|
|
147
|
+
io.rm(dir);
|
|
148
|
+
io.mkdir(dir);
|
|
149
|
+
const r = await io.run(process.execPath, [NIGHTLY_CHILD, config, dir, ...flags, ...args], {
|
|
150
|
+
cwd: wt,
|
|
151
|
+
env: { ...env, TICKET_GATE_ROOT: wt },
|
|
152
|
+
limitS,
|
|
153
|
+
killSignal: 'SIGINT',
|
|
154
|
+
graceS: GRACE_S,
|
|
155
|
+
});
|
|
156
|
+
if (r.timedOut)
|
|
157
|
+
return {
|
|
158
|
+
timedOut: true,
|
|
159
|
+
summary: null,
|
|
160
|
+
report: null,
|
|
161
|
+
extra: { noNumbers: 'cut-short', suspects: suspectsOf() },
|
|
162
|
+
};
|
|
163
|
+
const numbers = r.code === 0 ? readNumbers(io, dir) : null;
|
|
164
|
+
if (numbers === null)
|
|
165
|
+
return noNumbers({
|
|
166
|
+
runs,
|
|
167
|
+
out: r.out,
|
|
168
|
+
reason: r.out.trim().split('\n').at(-1) || `exit ${r.code}`,
|
|
169
|
+
suspects: suspectsOf(),
|
|
170
|
+
});
|
|
171
|
+
const blocking = numbers.over.filter((o) => o.blocking);
|
|
172
|
+
const advisoryOver = numbers.over.length - blocking.length;
|
|
173
|
+
const red = blocking.length > 0;
|
|
174
|
+
const count = Object.keys(numbers.medians).length;
|
|
175
|
+
return {
|
|
176
|
+
timedOut: false,
|
|
177
|
+
summary: summaryOf({
|
|
178
|
+
expected: count - numbers.over.length,
|
|
179
|
+
unexpected: blocking.length,
|
|
180
|
+
flaky: advisoryOver,
|
|
181
|
+
scenarios: count,
|
|
182
|
+
runs,
|
|
183
|
+
failed: blocking.map((o) => o.scenario),
|
|
184
|
+
}),
|
|
185
|
+
report: { results: 'results.json', traces: 'traces' },
|
|
186
|
+
extra: {
|
|
187
|
+
...numbers,
|
|
188
|
+
promote: promotable(history, suite, numbers),
|
|
189
|
+
...(red ? { suspects: suspectsOf() } : {}),
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
} catch (e) {
|
|
193
|
+
return noNumbers({
|
|
194
|
+
runs,
|
|
195
|
+
out: '',
|
|
196
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
197
|
+
suspects: suspectsOf(),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Argument parsing, render budgets and result comparison for the React profiler.
|
|
2
|
+
// Every project value arrives in `cfg`.
|
|
3
|
+
import { round } from './trace.mjs';
|
|
4
|
+
|
|
5
|
+
export const usageOf = (cfg) =>
|
|
6
|
+
`usage: ${cfg.command} [--build] [--scenarios=${Object.keys(cfg.scenarios).join(',')}] [--runs=N (integer >= 1)] [--out=<dir>] [--compare=<results.json>] [--max-renders=${cfg.usage.maxRenders}]`;
|
|
7
|
+
|
|
8
|
+
/** 'a:12,b:8' -> { a: 12, b: 8 }. Throws on a malformed pair, unknown scenario or non-integer. */
|
|
9
|
+
export function parseBudgets(text, cfg) {
|
|
10
|
+
const usage = usageOf(cfg);
|
|
11
|
+
const budgets = {};
|
|
12
|
+
for (const pair of text.split(',')) {
|
|
13
|
+
const [sc, n, ...rest] = pair.split(':');
|
|
14
|
+
if (rest.length > 0 || n === undefined || !/^\d+$/.test(n))
|
|
15
|
+
throw new Error(
|
|
16
|
+
`--max-renders: "${pair}" is not <scenario>:<integer>. ${usage}`,
|
|
17
|
+
);
|
|
18
|
+
if (!(sc in cfg.scenarios)) throw new Error(`unknown scenario ${sc}`);
|
|
19
|
+
budgets[sc] = Number(n);
|
|
20
|
+
}
|
|
21
|
+
return budgets;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What the run does with a budget: stderr lines and the exit code (1 over budget, else 0). */
|
|
25
|
+
export function applyBudget(summary, budgets, componentsFor) {
|
|
26
|
+
if (budgets === null) return { lines: [], code: 0 };
|
|
27
|
+
const lines = budgetFailures(summary, budgets, componentsFor).map(
|
|
28
|
+
(line) => `render budget exceeded: ${line}`,
|
|
29
|
+
);
|
|
30
|
+
return { lines, code: lines.length > 0 ? 1 : 0 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A scenario's median renders of its own components; null when it did not run. */
|
|
34
|
+
export function scenarioRenders(row, scenario, componentsFor) {
|
|
35
|
+
if (row === undefined) return null;
|
|
36
|
+
return componentsFor(scenario).reduce(
|
|
37
|
+
(sum, name) => sum + (row[name] ?? 0),
|
|
38
|
+
0,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Lines naming each scenario whose component renders exceed its budget; [] when all are within. */
|
|
43
|
+
export function budgetFailures(summary, budgets, componentsFor) {
|
|
44
|
+
return Object.entries(budgets)
|
|
45
|
+
.map(([sc, max]) => [
|
|
46
|
+
sc,
|
|
47
|
+
max,
|
|
48
|
+
scenarioRenders(summary[sc], sc, componentsFor),
|
|
49
|
+
])
|
|
50
|
+
.filter(([, max, renders]) => renders === null || renders > max)
|
|
51
|
+
.map(
|
|
52
|
+
([sc, max, renders]) =>
|
|
53
|
+
`${sc}: median ${componentsFor(sc).join(' + ')} renders ${renders ?? 'missing'} > budget ${max}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** argv = process.argv.slice(2). Throws on anything it does not know. */
|
|
58
|
+
export function parseArgs(argv, cfg) {
|
|
59
|
+
const usage = usageOf(cfg);
|
|
60
|
+
const opts = {
|
|
61
|
+
scenarios: Object.keys(cfg.scenarios),
|
|
62
|
+
runs: 3,
|
|
63
|
+
out: cfg.out,
|
|
64
|
+
build: false,
|
|
65
|
+
compare: null,
|
|
66
|
+
maxRenders: null,
|
|
67
|
+
};
|
|
68
|
+
for (const a of argv) {
|
|
69
|
+
if (a === '--build') opts.build = true;
|
|
70
|
+
else if (a.startsWith('--scenarios='))
|
|
71
|
+
opts.scenarios = a.slice('--scenarios='.length).split(',');
|
|
72
|
+
else if (a.startsWith('--runs='))
|
|
73
|
+
opts.runs = Number(a.slice('--runs='.length));
|
|
74
|
+
else if (a.startsWith('--out=')) opts.out = a.slice('--out='.length);
|
|
75
|
+
else if (a.startsWith('--compare='))
|
|
76
|
+
opts.compare = a.slice('--compare='.length);
|
|
77
|
+
else if (a.startsWith('--max-renders='))
|
|
78
|
+
opts.maxRenders = parseBudgets(a.slice('--max-renders='.length), cfg);
|
|
79
|
+
else throw new Error(usage);
|
|
80
|
+
}
|
|
81
|
+
const { runs } = opts;
|
|
82
|
+
if (!Number.isInteger(runs) || runs < 1) throw new Error(usage);
|
|
83
|
+
for (const sc of opts.scenarios)
|
|
84
|
+
if (!(sc in cfg.scenarios)) throw new Error(`unknown scenario ${sc}`);
|
|
85
|
+
for (const sc of Object.keys(opts.maxRenders ?? {}))
|
|
86
|
+
if (!opts.scenarios.includes(sc))
|
|
87
|
+
throw new Error(
|
|
88
|
+
`--max-renders names ${sc}, which --scenarios does not run`,
|
|
89
|
+
);
|
|
90
|
+
return opts;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function percent(now, base) {
|
|
94
|
+
const pct =
|
|
95
|
+
base === 0 ? (now === 0 ? 0 : null) : round(((now - base) / base) * 100);
|
|
96
|
+
return pct;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One row per (scenario, metric) of `summary`; base/delta/pct are null when the baseline lacks it. */
|
|
100
|
+
export function compare(summary, baseline) {
|
|
101
|
+
const rows = [];
|
|
102
|
+
for (const [scenario, metrics] of Object.entries(summary))
|
|
103
|
+
for (const [metric, now] of Object.entries(metrics)) {
|
|
104
|
+
const base = baseline.summary?.[scenario]?.[metric] ?? null;
|
|
105
|
+
if (base === null) {
|
|
106
|
+
rows.push({ scenario, metric, now, base, delta: null, pct: null });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
rows.push({
|
|
110
|
+
scenario,
|
|
111
|
+
metric,
|
|
112
|
+
now,
|
|
113
|
+
base,
|
|
114
|
+
delta: round(now - base),
|
|
115
|
+
pct: percent(now, base),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return rows;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const SETUP_MISSING =
|
|
122
|
+
'baseline has no setup record (written before setup records existed)';
|
|
123
|
+
|
|
124
|
+
/** Lines naming each differing setup field. The commit sha is never flagged. */
|
|
125
|
+
export function setupDiff(meta, baseMeta) {
|
|
126
|
+
if (!baseMeta) return [SETUP_MISSING];
|
|
127
|
+
return ['buildFlags', 'chromium', 'runs']
|
|
128
|
+
.filter((field) => meta[field] !== baseMeta[field])
|
|
129
|
+
.map((field) => `${field}: ${baseMeta[field]} -> ${meta[field]}`);
|
|
130
|
+
}
|