@ticket-gate/engine 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 +81 -2
- package/bin/ticket-gate.mjs +261 -0
- package/checks/assertions-unguarded.mjs +31 -0
- package/checks/assertions.mjs +319 -0
- package/checks/cache-report.mjs +313 -0
- package/checks/docs-stale.mjs +69 -0
- package/checks/duplication.mjs +258 -0
- package/checks/ratchet-options.mjs +51 -0
- package/checks/surface.mjs +397 -0
- package/cli.mjs +379 -0
- package/commands-config.mjs +55 -0
- package/config.mjs +597 -0
- package/digest.mjs +195 -0
- package/env-name.mjs +24 -0
- package/failures.mjs +256 -0
- package/fu.mjs +458 -0
- package/graph/js.mjs +517 -0
- package/graph/load-time.mjs +309 -0
- package/graph/typescript.mjs +28 -0
- package/hooks/pre-tool-use.mjs +213 -0
- package/hooks/stop.mjs +495 -0
- package/is-main.mjs +15 -0
- package/kill-group.mjs +13 -0
- package/ledger.mjs +186 -0
- package/lock.d.mts +72 -0
- package/lock.mjs +329 -0
- package/move-proof.mjs +206 -0
- package/mutations/check.mjs +506 -0
- package/mutations/cli.mjs +361 -0
- package/mutations/exec.mjs +120 -0
- package/mutations/gate.mjs +148 -0
- package/mutations/harness.mjs +240 -0
- package/mutations/kinds.mjs +148 -0
- package/mutations/locate.mjs +71 -0
- package/mutations/project.mjs +23 -0
- package/mutations/registry.mjs +173 -0
- package/mutations/rotate.mjs +419 -0
- package/mutations/rotation-status.mjs +93 -0
- package/mutations/run.mjs +252 -0
- package/mutations/select.mjs +333 -0
- package/mutations/served.mjs +216 -0
- package/mutations/validate.mjs +86 -0
- package/mutations/verdict.mjs +159 -0
- package/nightly/cli.mjs +30 -0
- package/nightly/define.mjs +111 -0
- package/nightly/e2e-runs.mjs +73 -0
- package/nightly/gate.mjs +25 -0
- package/nightly/io.mjs +109 -0
- package/nightly/launchd.mjs +42 -0
- package/nightly/record.mjs +231 -0
- package/nightly/run.mjs +470 -0
- package/nightly/schedule.mjs +76 -0
- package/nightly/seed.mjs +78 -0
- package/nightly/status.mjs +189 -0
- package/nightly/suites.mjs +43 -0
- package/nightly-config.mjs +38 -0
- package/nightly-window.mjs +6 -0
- package/pack-hooks-all.mjs +15 -0
- package/pack-hooks.mjs +26 -0
- package/package.json +19 -4
- package/plan.mjs +272 -0
- package/precheck.mjs +90 -0
- package/project.mjs +21 -0
- package/ratchet.mjs +431 -0
- package/receipt.mjs +34 -0
- package/review-map.mjs +432 -0
- package/root.mjs +33 -0
- package/run.mjs +291 -0
- package/runners/playwright.mjs +132 -0
- package/score/adapters/stryker.mjs +111 -0
- package/score/base.mjs +82 -0
- package/score/cli.mjs +182 -0
- package/score/judge.mjs +107 -0
- package/score/lines.mjs +40 -0
- package/score/schema.mjs +68 -0
- package/score/survivors.mjs +103 -0
- package/score/targets.mjs +34 -0
- package/score-config.mjs +120 -0
- package/selection/check.mjs +45 -0
- package/selection/cli.mjs +267 -0
- package/selection/define.mjs +55 -0
- package/selection/for.mjs +235 -0
- package/selection/graphs.mjs +38 -0
- package/selection/project.mjs +11 -0
- package/selection/select.mjs +159 -0
- package/selection/units.mjs +646 -0
- package/selection/value-rules.mjs +142 -0
- package/selection-config.mjs +76 -0
- package/serve.mjs +142 -0
- package/summary.mjs +34 -0
- package/tree.mjs +211 -0
package/nightly/run.mjs
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { existsSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { bothNames, envName } from '../env-name.mjs';
|
|
5
|
+
import { realIo } from './io.mjs';
|
|
6
|
+
import { install } from './launchd.mjs';
|
|
7
|
+
import {
|
|
8
|
+
classifyRun,
|
|
9
|
+
mapDrift,
|
|
10
|
+
nightlyDirOf,
|
|
11
|
+
readNightly,
|
|
12
|
+
writeOutputs,
|
|
13
|
+
} from './record.mjs';
|
|
14
|
+
import {
|
|
15
|
+
RETRY_MS,
|
|
16
|
+
dayOf,
|
|
17
|
+
deadlineFor,
|
|
18
|
+
decide,
|
|
19
|
+
triggerFor,
|
|
20
|
+
} from './schedule.mjs';
|
|
21
|
+
import {
|
|
22
|
+
readLock,
|
|
23
|
+
readReleased,
|
|
24
|
+
reapStale,
|
|
25
|
+
release,
|
|
26
|
+
tryTake,
|
|
27
|
+
} from '../lock.mjs';
|
|
28
|
+
import { runRotation } from '../mutations/rotate.mjs';
|
|
29
|
+
|
|
30
|
+
export const MUTATION_BUDGET_MS = 3_600_000,
|
|
31
|
+
MUTATION_BATCH = 10,
|
|
32
|
+
MUTATION_GRACE_S = 120;
|
|
33
|
+
const UP_TO_DATE_MS = 24 * 3_600_000;
|
|
34
|
+
|
|
35
|
+
// ------------------------------------------------------------------ pure
|
|
36
|
+
|
|
37
|
+
/** The trimmed non-empty value of `NAME=` in a dotenv text, else null. */
|
|
38
|
+
export function envValue(envText, name) {
|
|
39
|
+
const m = new RegExp(`^${name}=(.*)$`, 'm').exec(envText);
|
|
40
|
+
const v = m ? m[1].trim() : '';
|
|
41
|
+
return v === '' ? null : v;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ------------------------------------------------- mutation phase
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
compactLedger,
|
|
48
|
+
readLedger,
|
|
49
|
+
rotationOrder,
|
|
50
|
+
} from '../mutations/rotate.mjs';
|
|
51
|
+
|
|
52
|
+
const noPhase = (status, reason, over = {}) => ({
|
|
53
|
+
status,
|
|
54
|
+
reason,
|
|
55
|
+
stoppedBy: null,
|
|
56
|
+
ms: 0,
|
|
57
|
+
live: 0,
|
|
58
|
+
ran: 0,
|
|
59
|
+
killed: 0,
|
|
60
|
+
survived: 0,
|
|
61
|
+
error: 0,
|
|
62
|
+
oldestProofDays: null,
|
|
63
|
+
unproven: 0,
|
|
64
|
+
survivors: [],
|
|
65
|
+
...over,
|
|
66
|
+
});
|
|
67
|
+
const phaseError = (e) =>
|
|
68
|
+
noPhase('error', String(e?.message ?? e).slice(0, 200));
|
|
69
|
+
|
|
70
|
+
export function runMutationPhase(
|
|
71
|
+
io,
|
|
72
|
+
{
|
|
73
|
+
wt,
|
|
74
|
+
holderId,
|
|
75
|
+
budgetMs = MUTATION_BUDGET_MS,
|
|
76
|
+
batch = MUTATION_BATCH,
|
|
77
|
+
command,
|
|
78
|
+
...rest
|
|
79
|
+
},
|
|
80
|
+
) {
|
|
81
|
+
return runRotation(io, {
|
|
82
|
+
...rest,
|
|
83
|
+
cwd: wt,
|
|
84
|
+
budgetMs,
|
|
85
|
+
batch,
|
|
86
|
+
graceS: MUTATION_GRACE_S,
|
|
87
|
+
command,
|
|
88
|
+
env: bothNames('STACK_LOCK_OWNER', holderId),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The rotation budget in ms from NIGHTLY_MUTATION_MIN (minutes), or `dflt` when unset, empty or not a number. */
|
|
93
|
+
export function envMutationBudgetMs(env, dflt) {
|
|
94
|
+
const raw = envName(env, 'NIGHTLY_MUTATION_MIN');
|
|
95
|
+
return raw !== undefined && raw !== '' && Number.isFinite(Number(raw))
|
|
96
|
+
? Number(raw) * 60_000
|
|
97
|
+
: dflt;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------- the run
|
|
101
|
+
|
|
102
|
+
const SUITE_BASE = {
|
|
103
|
+
status: 'error',
|
|
104
|
+
reason: null,
|
|
105
|
+
reachedPlaywright: false,
|
|
106
|
+
stats: null,
|
|
107
|
+
redSpecs: [],
|
|
108
|
+
flakySpecs: [],
|
|
109
|
+
report: null,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Run once. Resolves with the record, { status: 'up-to-date' } or { status: 'aborted' }. */
|
|
113
|
+
export async function runNightly(
|
|
114
|
+
io,
|
|
115
|
+
{ now: force = false, mutationBudgetMs, mutationBatch, config } = {},
|
|
116
|
+
) {
|
|
117
|
+
if (config === undefined)
|
|
118
|
+
throw new TypeError('runNightly: a nightly config is required');
|
|
119
|
+
const dir = nightlyDirOf(io);
|
|
120
|
+
const lock = io.lockPath;
|
|
121
|
+
const startMs = io.now();
|
|
122
|
+
const trigger = triggerFor(startMs, undefined, config.window);
|
|
123
|
+
const deadlineMs = deadlineFor(trigger, startMs, undefined, config.window);
|
|
124
|
+
const base = (over) => ({
|
|
125
|
+
v: 1,
|
|
126
|
+
sha: '',
|
|
127
|
+
ranAt: new Date(startMs).toISOString(),
|
|
128
|
+
endedAt: '',
|
|
129
|
+
ms: 0,
|
|
130
|
+
trigger,
|
|
131
|
+
status: 'error',
|
|
132
|
+
reason: null,
|
|
133
|
+
reachedPlaywright: false,
|
|
134
|
+
stats: null,
|
|
135
|
+
redSpecs: [],
|
|
136
|
+
flakySpecs: [],
|
|
137
|
+
report: null,
|
|
138
|
+
finalReset: null,
|
|
139
|
+
mutations: null,
|
|
140
|
+
...over,
|
|
141
|
+
});
|
|
142
|
+
const record = (over, redTests = [], first = null, suiteReports = {}) => {
|
|
143
|
+
const endMs = io.now();
|
|
144
|
+
const line = base({
|
|
145
|
+
...over,
|
|
146
|
+
endedAt: new Date(endMs).toISOString(),
|
|
147
|
+
ms: endMs - startMs,
|
|
148
|
+
});
|
|
149
|
+
writeOutputs(dir, line, redTests, first, suiteReports, config.suites);
|
|
150
|
+
io.log(`nightly: ${line.status}${line.reason ? ` (${line.reason})` : ''}`);
|
|
151
|
+
return line;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// (1) wait for an idle stack
|
|
155
|
+
for (;;) {
|
|
156
|
+
reapStale(lock, { now: io.now, alive: io.alive });
|
|
157
|
+
const holder = readLock(lock, { now: io.now });
|
|
158
|
+
const released = readReleased(lock);
|
|
159
|
+
if (force) {
|
|
160
|
+
if (holder !== null) {
|
|
161
|
+
io.log(
|
|
162
|
+
`nightly: stack lock is held (${holder.owner}, pid ${holder.pid}); not overriding. Try again when it is free.`,
|
|
163
|
+
);
|
|
164
|
+
return { status: 'aborted' };
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
const d = decide({ holder, released, nowMs: io.now(), deadlineMs });
|
|
168
|
+
if (d === 'skipped-busy') return record({ status: 'skipped-busy' });
|
|
169
|
+
if (d === 'defer') {
|
|
170
|
+
await io.sleep(RETRY_MS);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// (2) take it
|
|
175
|
+
const got = tryTake(lock, {
|
|
176
|
+
owner: 'nightly',
|
|
177
|
+
cwd: io.mainCheckout,
|
|
178
|
+
now: io.now,
|
|
179
|
+
alive: io.alive,
|
|
180
|
+
env: {},
|
|
181
|
+
});
|
|
182
|
+
if (got.ok) return withLock(got.holder);
|
|
183
|
+
if (force) return { status: 'aborted' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function withLock(holder) {
|
|
187
|
+
try {
|
|
188
|
+
io.caffeinate(process.pid);
|
|
189
|
+
return await body(holder);
|
|
190
|
+
} finally {
|
|
191
|
+
release(lock, holder, { now: io.now });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function body(holder) {
|
|
196
|
+
let sha = '';
|
|
197
|
+
const finish = (over, ...rest) => record({ sha, ...over }, ...rest);
|
|
198
|
+
const fail = (reason) => finish({ status: 'error', reason });
|
|
199
|
+
const main = io.mainCheckout;
|
|
200
|
+
const payloads = config.payloads;
|
|
201
|
+
// (3) payload preflights: before anything is fetched or built
|
|
202
|
+
for (const p of payloads) {
|
|
203
|
+
const pf = await p.preflight(main, io);
|
|
204
|
+
if (pf.reason) return fail(pf.reason);
|
|
205
|
+
}
|
|
206
|
+
// (4)
|
|
207
|
+
const { name: remote, branch } = config.remote;
|
|
208
|
+
const fetched = await io.run('git', ['fetch', remote, branch], {
|
|
209
|
+
cwd: main,
|
|
210
|
+
limitS: 300,
|
|
211
|
+
});
|
|
212
|
+
if (fetched.code !== 0) return fail('fetch-failed');
|
|
213
|
+
const rev = await io.run('git', ['rev-parse', `${remote}/${branch}`], {
|
|
214
|
+
cwd: main,
|
|
215
|
+
limitS: 30,
|
|
216
|
+
});
|
|
217
|
+
sha = rev.out.trim();
|
|
218
|
+
if (rev.code !== 0 || !/^[0-9a-f]{40}$/.test(sha))
|
|
219
|
+
return fail('fetch-failed');
|
|
220
|
+
const last = readNightly(dir).history.at(-1);
|
|
221
|
+
if (
|
|
222
|
+
!force &&
|
|
223
|
+
last?.sha === sha &&
|
|
224
|
+
last.reachedPlaywright === true &&
|
|
225
|
+
startMs - Date.parse(last.ranAt) < UP_TO_DATE_MS
|
|
226
|
+
) {
|
|
227
|
+
io.log(
|
|
228
|
+
`nightly: ${sha.slice(0, 12)} already ran at ${last.ranAt}; up to date`,
|
|
229
|
+
);
|
|
230
|
+
return { status: 'up-to-date', sha };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// (5) the worktree
|
|
234
|
+
const sha12 = sha.slice(0, 12);
|
|
235
|
+
const day = dayOf(startMs);
|
|
236
|
+
const tree = config.worktree;
|
|
237
|
+
const root = join(io.tmpdir, tree.dir);
|
|
238
|
+
const wt = join(root, sha12);
|
|
239
|
+
const marker = join(wt, `.${tree.dir}-ready`);
|
|
240
|
+
if (existsSync(root))
|
|
241
|
+
for (const f of readdirSync(root))
|
|
242
|
+
if (f !== sha12) {
|
|
243
|
+
const old = join(root, f);
|
|
244
|
+
await io.run('git', ['worktree', 'remove', '--force', old], {
|
|
245
|
+
cwd: main,
|
|
246
|
+
limitS: 120,
|
|
247
|
+
});
|
|
248
|
+
rmSync(old, { recursive: true, force: true });
|
|
249
|
+
}
|
|
250
|
+
if (!io.exists(marker)) {
|
|
251
|
+
if (io.exists(wt)) {
|
|
252
|
+
io.rm(wt);
|
|
253
|
+
await io.run('git', ['worktree', 'prune'], { cwd: main, limitS: 60 });
|
|
254
|
+
}
|
|
255
|
+
const add = await io.run(
|
|
256
|
+
'git',
|
|
257
|
+
['worktree', 'add', '--detach', wt, sha],
|
|
258
|
+
{ cwd: main, limitS: 300 },
|
|
259
|
+
);
|
|
260
|
+
if (add.code !== 0) return fail('worktree-failed');
|
|
261
|
+
const install = await io.run(tree.install[0], tree.install.slice(1), {
|
|
262
|
+
cwd: wt,
|
|
263
|
+
limitS: 900,
|
|
264
|
+
});
|
|
265
|
+
if (install.code !== 0) return fail('install-failed');
|
|
266
|
+
for (const argv of tree.prepare ?? []) {
|
|
267
|
+
const step = await io.run(argv[0], argv.slice(1), {
|
|
268
|
+
cwd: wt,
|
|
269
|
+
limitS: 600,
|
|
270
|
+
});
|
|
271
|
+
if (step.code !== 0) return fail('install-failed');
|
|
272
|
+
}
|
|
273
|
+
io.writeFile(marker, `${sha}\n`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// (6) env files: the main checkout's, copied as is; secrets in them are never logged
|
|
277
|
+
for (const e of tree.env ?? []) {
|
|
278
|
+
const src = join(main, e.path);
|
|
279
|
+
if (!io.exists(src) && e.require === undefined) return fail(e.reason);
|
|
280
|
+
const text = io.exists(src) ? io.readFile(src) : '';
|
|
281
|
+
if (e.require !== undefined) {
|
|
282
|
+
const value = envValue(text, e.require);
|
|
283
|
+
if (value === null) return fail(e.reason);
|
|
284
|
+
}
|
|
285
|
+
io.writeFile(join(wt, e.path), text);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// (7) payload befores: the stack must be up (the nightly never starts it) and reset
|
|
289
|
+
let over = null;
|
|
290
|
+
let runAfter = true;
|
|
291
|
+
for (const p of payloads) {
|
|
292
|
+
if (!p.before) continue;
|
|
293
|
+
const b = await p.before(wt, io);
|
|
294
|
+
if (b.ok === true) continue;
|
|
295
|
+
over = { status: 'error', reason: b.reason };
|
|
296
|
+
runAfter = b.runAfter === true;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
const runAfters = async () => {
|
|
300
|
+
const withAfter = payloads.filter((p) => p.after);
|
|
301
|
+
if (withAfter.length === 0) return null;
|
|
302
|
+
let result = 'pass';
|
|
303
|
+
for (const p of withAfter)
|
|
304
|
+
if ((await p.after(main, io)) !== 'pass') result = 'fail';
|
|
305
|
+
return result;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// (8) the suites, in config order, each at its own limit
|
|
309
|
+
const suites = {};
|
|
310
|
+
const suiteReports = {};
|
|
311
|
+
let first = null;
|
|
312
|
+
let firstMs = null;
|
|
313
|
+
if (over === null)
|
|
314
|
+
for (const [i, suite] of config.suites.entries()) {
|
|
315
|
+
const out = join(
|
|
316
|
+
io.tmpdir,
|
|
317
|
+
i === 0
|
|
318
|
+
? `${tree.dir}-${sha12}.pw.json`
|
|
319
|
+
: `${tree.dir}-${sha12}-${suite.name}.pw.json`,
|
|
320
|
+
);
|
|
321
|
+
const sdir = join(dir, suite.name, `${sha12}-${day}`);
|
|
322
|
+
const t0 = io.now();
|
|
323
|
+
const res = await suite.runner.run({
|
|
324
|
+
wt,
|
|
325
|
+
io,
|
|
326
|
+
args: suite.args ?? [],
|
|
327
|
+
limitS: suite.limitS,
|
|
328
|
+
env: bothNames('STACK_LOCK_OWNER', holder.id),
|
|
329
|
+
out,
|
|
330
|
+
dir: sdir,
|
|
331
|
+
});
|
|
332
|
+
const ms = io.now() - t0;
|
|
333
|
+
const summary = res.timedOut ? null : (res.summary ?? null);
|
|
334
|
+
const s = summary?.stats;
|
|
335
|
+
let stop = null;
|
|
336
|
+
if (res.timedOut) stop = { status: 'error', reason: 'cut-short' };
|
|
337
|
+
else if (!s) stop = { status: 'error', reason: 'no-report' };
|
|
338
|
+
else if (s.expected + s.unexpected + s.flaky + s.skipped === 0)
|
|
339
|
+
stop = { status: 'error', reason: 'zero-tests' };
|
|
340
|
+
if (i === 0) {
|
|
341
|
+
firstMs = ms;
|
|
342
|
+
first = { summary, report: res.report ?? null };
|
|
343
|
+
over = stop;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const c =
|
|
347
|
+
stop ??
|
|
348
|
+
classifyRun({
|
|
349
|
+
summary,
|
|
350
|
+
reason: null,
|
|
351
|
+
history: readNightly(dir).history,
|
|
352
|
+
suite: suite.name,
|
|
353
|
+
});
|
|
354
|
+
const relReport = `${suite.name}/${sha12}-${day}/report.json`;
|
|
355
|
+
suites[suite.name] = {
|
|
356
|
+
...SUITE_BASE,
|
|
357
|
+
status: c.status,
|
|
358
|
+
reason: c.reason,
|
|
359
|
+
...(stop === null
|
|
360
|
+
? {
|
|
361
|
+
reachedPlaywright: true,
|
|
362
|
+
stats: summary.stats,
|
|
363
|
+
redSpecs: c.redSpecs,
|
|
364
|
+
flakySpecs: c.flakySpecs,
|
|
365
|
+
report: relReport,
|
|
366
|
+
}
|
|
367
|
+
: {}),
|
|
368
|
+
ms,
|
|
369
|
+
...(res.extra !== undefined ? { extra: res.extra } : {}),
|
|
370
|
+
};
|
|
371
|
+
if (stop === null) suiteReports[suite.name] = res.report ?? null;
|
|
372
|
+
}
|
|
373
|
+
const suitesPart = {
|
|
374
|
+
...(Object.keys(suites).length > 0 ? { suites } : {}),
|
|
375
|
+
// the first suite's own time, so a later suite does not count toward it
|
|
376
|
+
...(firstMs !== null && config.suites.length > 1 ? { firstMs } : {}),
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// (8b) rotate registry mutations while the stack and lock are ours
|
|
380
|
+
let mutations = null;
|
|
381
|
+
if (over === null) {
|
|
382
|
+
const budget =
|
|
383
|
+
mutationBudgetMs ??
|
|
384
|
+
envMutationBudgetMs(process.env, MUTATION_BUDGET_MS);
|
|
385
|
+
if (budget <= 0 || !config.rotate)
|
|
386
|
+
mutations = noPhase('skipped', 'disabled');
|
|
387
|
+
else if (trigger !== 'scheduled' && !force)
|
|
388
|
+
mutations = noPhase('skipped', 'catch-up');
|
|
389
|
+
else {
|
|
390
|
+
mutations = await runMutationPhase(io, {
|
|
391
|
+
dir,
|
|
392
|
+
wt,
|
|
393
|
+
sha,
|
|
394
|
+
holderId: holder.id,
|
|
395
|
+
command: config.rotate.command,
|
|
396
|
+
startMs: io.now(),
|
|
397
|
+
deadlineMs,
|
|
398
|
+
budgetMs: budget,
|
|
399
|
+
batch: mutationBatch ?? MUTATION_BATCH,
|
|
400
|
+
}).catch((e) => phaseError(e));
|
|
401
|
+
// a killed mutation must not poison tomorrow's worktree
|
|
402
|
+
const dirty = await io.run('git', ['status', '--porcelain'], {
|
|
403
|
+
cwd: wt,
|
|
404
|
+
limitS: 60,
|
|
405
|
+
});
|
|
406
|
+
if (dirty.code !== 0 || dirty.out.trim() !== '') io.rm(marker);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// (9) payload afters: leave the stack seeded for the main checkout
|
|
411
|
+
const finalReset = runAfter ? await runAfters() : null;
|
|
412
|
+
|
|
413
|
+
// (10) record
|
|
414
|
+
if (over !== null)
|
|
415
|
+
return finish(
|
|
416
|
+
{ ...over, finalReset, ...suitesPart },
|
|
417
|
+
[],
|
|
418
|
+
null,
|
|
419
|
+
suiteReports,
|
|
420
|
+
);
|
|
421
|
+
const { summary } = first;
|
|
422
|
+
let drift = [];
|
|
423
|
+
try {
|
|
424
|
+
drift = config.drift
|
|
425
|
+
? mapDrift(summary.navigation, config.drift.headers(wt), config.drift)
|
|
426
|
+
: [];
|
|
427
|
+
} catch {
|
|
428
|
+
drift = [];
|
|
429
|
+
}
|
|
430
|
+
const c = classifyRun({
|
|
431
|
+
summary,
|
|
432
|
+
reason: null,
|
|
433
|
+
history: readNightly(dir).history,
|
|
434
|
+
drift,
|
|
435
|
+
});
|
|
436
|
+
return record(
|
|
437
|
+
{
|
|
438
|
+
sha,
|
|
439
|
+
status: c.status,
|
|
440
|
+
reason: c.reason,
|
|
441
|
+
reachedPlaywright: true,
|
|
442
|
+
stats: summary.stats,
|
|
443
|
+
redSpecs: c.redSpecs,
|
|
444
|
+
flakySpecs: c.flakySpecs,
|
|
445
|
+
...(c.drift ? { drift: c.drift } : {}),
|
|
446
|
+
report: `${sha12}-${day}.json`,
|
|
447
|
+
finalReset,
|
|
448
|
+
mutations,
|
|
449
|
+
...suitesPart,
|
|
450
|
+
},
|
|
451
|
+
summary.redTests,
|
|
452
|
+
first.report,
|
|
453
|
+
suiteReports,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export async function main(argv, config, io = realIo()) {
|
|
459
|
+
const known = ['--now', '--install'];
|
|
460
|
+
const { run, install: inst } = config.commands;
|
|
461
|
+
if (argv.length > 1 || argv.some((a) => !known.includes(a))) {
|
|
462
|
+
console.error(`usage: ${run} [--now] | ${inst}`);
|
|
463
|
+
return 2;
|
|
464
|
+
}
|
|
465
|
+
if (argv[0] === '--install') {
|
|
466
|
+
return install(io, config) ?? 0;
|
|
467
|
+
}
|
|
468
|
+
const r = await runNightly(io, { now: argv[0] === '--now', config });
|
|
469
|
+
return r.status === 'aborted' ? 1 : 0;
|
|
470
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { NIGHTLY_DEFAULTS } from './define.mjs';
|
|
2
|
+
|
|
3
|
+
export const IDLE_MS = 3_600_000,
|
|
4
|
+
RETRY_MS = 1_800_000,
|
|
5
|
+
E2E_LIMIT_S = 1800; // = the e2e step's limitS in gate.config.mjs
|
|
6
|
+
|
|
7
|
+
// ------------------------------------------------------------ local clock
|
|
8
|
+
|
|
9
|
+
/** Local wall-clock parts of `ms` in `tz` (default: the machine zone). */
|
|
10
|
+
function localParts(ms, tz) {
|
|
11
|
+
const f = new Intl.DateTimeFormat('en-GB', {
|
|
12
|
+
timeZone: tz,
|
|
13
|
+
hourCycle: 'h23',
|
|
14
|
+
year: 'numeric',
|
|
15
|
+
month: 'numeric',
|
|
16
|
+
day: 'numeric',
|
|
17
|
+
hour: 'numeric',
|
|
18
|
+
minute: 'numeric',
|
|
19
|
+
});
|
|
20
|
+
const p = Object.fromEntries(
|
|
21
|
+
f.formatToParts(new Date(ms)).map((x) => [x.type, Number(x.value)]),
|
|
22
|
+
);
|
|
23
|
+
return { y: p.year, mo: p.month, d: p.day, h: p.hour, mi: p.minute };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Epoch ms of a local wall-clock time (day overflow rolls over). */
|
|
27
|
+
function fromLocal({ y, mo, d, h, mi }, tz) {
|
|
28
|
+
const asUtc = Date.UTC(y, mo - 1, d, h, mi);
|
|
29
|
+
let guess = asUtc;
|
|
30
|
+
for (let i = 0; i < 2; i += 1) {
|
|
31
|
+
const l = localParts(guess, tz);
|
|
32
|
+
guess += asUtc - Date.UTC(l.y, l.mo - 1, l.d, l.h, l.mi);
|
|
33
|
+
}
|
|
34
|
+
return guess;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const hm = (t) => {
|
|
38
|
+
const [h, m] = t.split(':').map(Number);
|
|
39
|
+
return { h, mi: m };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function triggerFor(nowMs, tz, window = NIGHTLY_DEFAULTS.window) {
|
|
43
|
+
const { h, mi } = localParts(nowMs, tz);
|
|
44
|
+
const min = h * 60 + mi;
|
|
45
|
+
const start = hm(window.start);
|
|
46
|
+
const end = hm(window.end);
|
|
47
|
+
return min >= start.h * 60 + start.mi && min < end.h * 60 + end.mi
|
|
48
|
+
? 'scheduled'
|
|
49
|
+
: 'catch-up';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function deadlineFor(
|
|
53
|
+
trigger,
|
|
54
|
+
nowMs,
|
|
55
|
+
tz,
|
|
56
|
+
window = NIGHTLY_DEFAULTS.window,
|
|
57
|
+
) {
|
|
58
|
+
const l = localParts(nowMs, tz);
|
|
59
|
+
if (trigger === 'scheduled')
|
|
60
|
+
return fromLocal({ ...l, ...hm(window.end) }, tz);
|
|
61
|
+
const by = hm(window.catchUpBy);
|
|
62
|
+
const today = fromLocal({ ...l, ...by }, tz);
|
|
63
|
+
return today > nowMs ? today : fromLocal({ ...l, d: l.d + 1, ...by }, tz);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function decide({ holder, released, nowMs, deadlineMs }) {
|
|
67
|
+
// prettier-ignore
|
|
68
|
+
const busy = holder !== null || (released !== null && nowMs - Date.parse(released.lastReleasedAt) < IDLE_MS);
|
|
69
|
+
if (!busy) return 'start';
|
|
70
|
+
return nowMs + RETRY_MS <= deadlineMs ? 'defer' : 'skipped-busy';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const dayOf = (ms) => {
|
|
74
|
+
const l = localParts(ms);
|
|
75
|
+
return `${l.y}-${String(l.mo).padStart(2, '0')}-${String(l.d).padStart(2, '0')}`;
|
|
76
|
+
};
|
package/nightly/seed.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
import { byRanAtDesc } from './record.mjs';
|
|
4
|
+
|
|
5
|
+
export const SEED_CAP = 20;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* R8: suspect = the merge's files select the full suite or one of the red specs.
|
|
9
|
+
* The caller injects e2e-select's exports (this file must not import it: it
|
|
10
|
+
* imports walkReport from here). Falls back to the interim rule on a throw.
|
|
11
|
+
*/
|
|
12
|
+
export function selectionSelects({
|
|
13
|
+
select,
|
|
14
|
+
buildGraph,
|
|
15
|
+
root,
|
|
16
|
+
interim = () => false,
|
|
17
|
+
}) {
|
|
18
|
+
let graph = null;
|
|
19
|
+
return (files, redSpecs) => {
|
|
20
|
+
try {
|
|
21
|
+
graph ??= buildGraph(root);
|
|
22
|
+
const s = select({ root, changed: files, graph });
|
|
23
|
+
return s.mode === 'full' || s.specs.some((f) => redSpecs.includes(f));
|
|
24
|
+
} catch {
|
|
25
|
+
return interim(files, redSpecs);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const gitText = (git, ...args) => String(git(...args) ?? '').trim();
|
|
31
|
+
|
|
32
|
+
export function suspectsFirst(merges) {
|
|
33
|
+
const suspects = merges.filter((m) => m.suspect);
|
|
34
|
+
const others = merges.filter((m) => !m.suspect);
|
|
35
|
+
return [...suspects, ...others];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Merges since the last green sha (or the newest 20), suspects first. */
|
|
39
|
+
export function fixSeed({ latest, history, git, selects = () => false }) {
|
|
40
|
+
const lastGreen = history
|
|
41
|
+
.filter((r) => r.status === 'green')
|
|
42
|
+
.sort(byRanAtDesc)[0];
|
|
43
|
+
const lastGreenSha = lastGreen?.sha ?? null;
|
|
44
|
+
const redSpecs = latest.redSpecs ?? [];
|
|
45
|
+
const logArgs = ['log', '--first-parent', '--merges', '--format=%H%x09%s'];
|
|
46
|
+
const out = lastGreenSha
|
|
47
|
+
? gitText(git, ...logArgs, `${lastGreenSha}..${latest.sha}`)
|
|
48
|
+
: gitText(git, ...logArgs, '-n', String(SEED_CAP), latest.sha);
|
|
49
|
+
const merges = out
|
|
50
|
+
.split('\n')
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.slice(0, lastGreenSha ? undefined : SEED_CAP)
|
|
53
|
+
.map((row) => {
|
|
54
|
+
const [sha, ...rest] = row.split('\t');
|
|
55
|
+
const files = gitText(git, 'diff', '--name-only', `${sha}^1`, sha)
|
|
56
|
+
.split('\n')
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
return {
|
|
59
|
+
sha,
|
|
60
|
+
subject: rest.join('\t'),
|
|
61
|
+
suspect: selects(files, redSpecs),
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
redSpecs,
|
|
66
|
+
redTests: latest.redTests ?? [],
|
|
67
|
+
lastGreenSha,
|
|
68
|
+
merges: suspectsFirst(merges),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const short = (sha) => String(sha ?? '').slice(0, 12);
|
|
73
|
+
|
|
74
|
+
export const realGit = (...args) =>
|
|
75
|
+
execFileSync('git', args.flat(), {
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
78
|
+
});
|