rvlib-shipkit 1.8.0 → 1.9.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/CHANGELOG.md +32 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +31 -14
- package/src/assets/gen/generate-roll.ts +20 -7
- package/src/assets/kit-config.ts +9 -0
- package/src/assets/kits/identity-kit.ts +7 -1
- package/src/assets/run-assets.ts +3 -3
- package/src/assets/server/studio-server.ts +3 -3
- package/src/cli/cmd_Ci.ts +5 -4
- package/src/collections/providers.ts +1 -1
- package/src/core/check-runner.ts +13 -4
- package/src/core/discover-checks.ts +37 -8
- package/src/core/fix-scope.ts +5 -0
- package/src/core/floor-resolve.ts +2 -1
- package/src/core/git-root.ts +22 -12
- package/src/core/one-lib.ts +4 -2
- package/src/core/run-ci.ts +12 -11
- package/src/core/slots.ts +36 -6
- package/src/home/gui-app/ShipkitGui.swift +96 -0
- package/src/home/resources.ts +3 -0
- package/src/plan/planner.ts +21 -4
- package/src/plan/repo-source.ts +7 -3
- package/src/release/run-release.ts +17 -8
- package/src/release/unit-gate.ts +12 -5
- package/src/repo-config.ts +7 -0
- package/src/serve/hub-runtime.ts +3 -2
- package/src/serve/project-gutter.ts +5 -2
- package/src/serve/run-lanes.ts +7 -3
- package/src/smoke/run-smoke.ts +13 -6
- package/src/suite/judge-suites.ts +52 -4
- package/src/suite/suite-cli.ts +21 -13
- package/src/ts/typecheck-cache.ts +11 -0
- package/src/ts/typecheck-cli.ts +2 -1
package/src/core/run-ci.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
|
10
10
|
import { reportToMotherhome } from './motherhome.ts'
|
|
11
11
|
import { passScope, runCheckPass, type CheckOpts } from './run-check.ts'
|
|
12
12
|
import { poolLines } from './slots.ts'
|
|
13
|
+
import { fixPassRuns } from './fix-scope.ts'
|
|
13
14
|
import { runFixPass } from './run-fixers.ts'
|
|
14
15
|
import { formatLine } from './wire.ts'
|
|
15
16
|
import { outcomeCounts } from './outcome-words.ts'
|
|
@@ -29,8 +30,9 @@ export type CiOpts = {
|
|
|
29
30
|
onlyIds?: string[]
|
|
30
31
|
/** refresh STATUS.md after the run (default true — O4: every repo gets it) */
|
|
31
32
|
status?: boolean
|
|
32
|
-
/** repair the mechanical slots before gating
|
|
33
|
-
*
|
|
33
|
+
/** repair the mechanical slots before gating, verify mode under CI.
|
|
34
|
+
* Unset: on for a whole gate, off for a narrowed one (core/fix-scope.ts).
|
|
35
|
+
* core/run-fixers.ts owns the scope. */
|
|
34
36
|
fix?: boolean
|
|
35
37
|
/** also apply the repairs of the checks declaring `fix: 'ci'` during the
|
|
36
38
|
* fix pass */
|
|
@@ -138,15 +140,14 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
138
140
|
let c = o.config ?? (await loadRepoConfig(root))
|
|
139
141
|
// mechanical failures are not information, so they never cost a round trip
|
|
140
142
|
let fixFailed = false
|
|
141
|
-
// a NARROWED run repairs
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
// back as a test failure.
|
|
143
|
+
// a NARROWED run repairs nothing unless --fix asks, and even then only the
|
|
144
|
+
// mechanical slots. `bun run test` routes here and must answer the one
|
|
145
|
+
// question its name promises without leaving files in the agent's diff
|
|
146
|
+
// that it never wrote: in a shared tree the formatter rewrites every other
|
|
147
|
+
// agent's files too. The structural rule fixers rewrite package.json and
|
|
148
|
+
// repo config, and arming rewrites hooks/: the full gate's price alone
|
|
148
149
|
const narrowed = (o.onlyIds ?? []).length > 0
|
|
149
|
-
if (o
|
|
150
|
+
if (fixPassRuns(o)) {
|
|
150
151
|
const pass = await runFixPass(root, c, { rules: !narrowed && o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink, detail })
|
|
151
152
|
fixFailed = pass.failed
|
|
152
153
|
// a fixer that rewrote the config or moved a check file leaves the
|
|
@@ -158,7 +159,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
158
159
|
return 1
|
|
159
160
|
}
|
|
160
161
|
}
|
|
161
|
-
} else detail('skip: fix pass (--no-fix)')
|
|
162
|
+
} else detail(o.fix === false ? 'skip: fix pass (--no-fix)' : 'skip: fix pass (a --only run leaves the tree alone, --fix repairs first)')
|
|
162
163
|
const tier: TestTier = o.tests ?? envTier()
|
|
163
164
|
// announced BEFORE the run (under --verbose, and in every release log),
|
|
164
165
|
// so a log that died mid-way still says which tier it was asking for
|
package/src/core/slots.ts
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
// mutate lease's non blocking form (locked-write.ts tryLease): a slot whose
|
|
5
5
|
// holder died is free at once, and a live holder heartbeats it.
|
|
6
6
|
//
|
|
7
|
+
// A slot is a check, and a check is often a whole multi core tool (bun test
|
|
8
|
+
// workers, oxlint threads, tsc), so slots alone never bound the CPU. Past the
|
|
9
|
+
// cores the pool admits fewer slots in proportion to the 1 minute load, never
|
|
10
|
+
// fewer than one (admittedSlots). A slot already held is never taken back.
|
|
11
|
+
//
|
|
7
12
|
// The one constraint a reader breaks first: a process started BY a check
|
|
8
13
|
// (SHIPKIT_SLOT_HELD=1 in its environment) takes no slot. It already runs on
|
|
9
14
|
// its parent's, and a parent waiting on a child that waits for a slot the
|
|
@@ -12,6 +17,8 @@ import { mkdirSync, readdirSync } from 'fs'
|
|
|
12
17
|
import { homedir } from 'os'
|
|
13
18
|
import { join } from 'path'
|
|
14
19
|
import { leaseHolder, tryLease } from './locked-write.ts'
|
|
20
|
+
import { machineLoad } from './budget.ts'
|
|
21
|
+
import type { MachineLoad } from '../suite/judge-suites.ts'
|
|
15
22
|
import { PARALLEL_ENV, parallelCap, parallelCapInfo } from './parallel.ts'
|
|
16
23
|
|
|
17
24
|
/** where the slots live, for a test that must not touch the machine's pool */
|
|
@@ -19,7 +26,9 @@ export const SLOTS_DIR_ENV = 'SHIPKIT_SLOTS_DIR'
|
|
|
19
26
|
/** set for every process a check spawns: it runs on its parent's slot */
|
|
20
27
|
export const SLOT_HELD_ENV = 'SHIPKIT_SLOT_HELD'
|
|
21
28
|
|
|
22
|
-
|
|
29
|
+
/** `load`: the reading that scales what the pool admits. Omitted: the whole
|
|
30
|
+
* size, whatever the machine runs (a test's own pool) */
|
|
31
|
+
export type SlotPool = { dir: string; size: number; load?: () => MachineLoad }
|
|
23
32
|
|
|
24
33
|
export type SlotHolder = { slot: number; pid: number; repo: string; check: string; since: number }
|
|
25
34
|
|
|
@@ -37,9 +46,18 @@ export const autoGatePool = (): SlotPool => ({ dir: `${poolDir()}-auto-gate`, si
|
|
|
37
46
|
|
|
38
47
|
/** this process's pool, or null when it runs inside a check that holds one */
|
|
39
48
|
export function machinePool(): SlotPool | null {
|
|
40
|
-
return process.env[SLOT_HELD_ENV] === '1' ? null : { dir: poolDir(), size: parallelCap() }
|
|
49
|
+
return process.env[SLOT_HELD_ENV] === '1' ? null : { dir: poolDir(), size: parallelCap(), load: machineLoad }
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
/** how many of `size` slots a machine at this load admits: all of them up to
|
|
53
|
+
* a load of one per core, then size × cores / load, never below one. At 20
|
|
54
|
+
* on 8 cores a pool of 8 admits 3, at 81 it admits 1 */
|
|
55
|
+
export const admittedSlots = (size: number, m: MachineLoad): number =>
|
|
56
|
+
Math.max(1, Math.min(size, Math.floor((size * m.cores) / Math.max(m.cores, m.load1))))
|
|
57
|
+
|
|
58
|
+
const admitWords = (size: number, admitted: number, m: MachineLoad): string =>
|
|
59
|
+
`the pool admits ${admitted} of its ${size} slots at load ${m.load1.toFixed(1)} on ${m.cores} cores`
|
|
60
|
+
|
|
43
61
|
/** who holds which slot right now, dead holders left out. Every slot file in
|
|
44
62
|
* the dir is read, so a slot taken under a larger size still shows. */
|
|
45
63
|
export function poolOccupancy(pool: SlotPool = { dir: poolDir(), size: parallelCap() }): { size: number; dir: string; held: SlotHolder[] } {
|
|
@@ -64,25 +82,37 @@ export function poolLines(): string[] {
|
|
|
64
82
|
...(info.problem == null ? [] : [`WARN ${info.problem}`]),
|
|
65
83
|
pool == null
|
|
66
84
|
? `ok: parallel: inside a check that holds a machine slot, so this run takes none (up to ${info.cap} at once in this process)`
|
|
67
|
-
: `ok: parallel: one machine pool of ${info.cap} slot(s) shared by every shipkit process, ${poolOccupancy(pool).held.length} held now (${why})`,
|
|
85
|
+
: `ok: parallel: one machine pool of ${info.cap} slot(s) shared by every shipkit process, ${poolOccupancy(pool).held.length} held now (${why})${loadNote(pool)}`,
|
|
68
86
|
]
|
|
69
87
|
}
|
|
70
88
|
|
|
89
|
+
const loadNote = (pool: SlotPool): string => {
|
|
90
|
+
const m = pool.load?.()
|
|
91
|
+
if (m == null) return ''
|
|
92
|
+
const admitted = admittedSlots(pool.size, m)
|
|
93
|
+
return admitted < pool.size ? `; ${admitWords(pool.size, admitted, m)}` : ''
|
|
94
|
+
}
|
|
95
|
+
|
|
71
96
|
/** wait for a free slot and hold it until release(). null = abort said stop. */
|
|
72
97
|
export async function takeSlot(
|
|
73
98
|
pool: SlotPool,
|
|
74
99
|
who: { repo: string; check: string },
|
|
75
|
-
o: { abort?: () => boolean; pollMs?: number; onWait?: (held: SlotHolder[]) => void } = {},
|
|
100
|
+
o: { abort?: () => boolean; pollMs?: number; onWait?: (held: SlotHolder[], why: string) => void } = {},
|
|
76
101
|
): Promise<{ slot: number; release: () => void } | null> {
|
|
77
102
|
mkdirSync(pool.dir, { recursive: true })
|
|
78
103
|
let heard = false
|
|
79
104
|
for (;;) {
|
|
80
|
-
|
|
105
|
+
const m = pool.load?.() ?? null
|
|
106
|
+
const admitted = m == null ? pool.size : admittedSlots(pool.size, m)
|
|
107
|
+
for (let slot = 0; slot < admitted; slot++) {
|
|
81
108
|
const lease = tryLease(join(pool.dir, `slot-${slot}`), who.check, { repo: who.repo, check: who.check })
|
|
82
109
|
if (lease != null) return { slot, release: lease.release }
|
|
83
110
|
}
|
|
84
111
|
if (o.abort?.() === true) return null
|
|
85
|
-
if (!heard) {
|
|
112
|
+
if (!heard) {
|
|
113
|
+
heard = true
|
|
114
|
+
o.onWait?.(poolOccupancy(pool).held, m != null && admitted < pool.size ? admitWords(pool.size, admitted, m) : `all ${pool.size} slots are held`)
|
|
115
|
+
}
|
|
86
116
|
await Bun.sleep(o.pollMs ?? SLOT_POLL_MS)
|
|
87
117
|
}
|
|
88
118
|
}
|
|
@@ -130,6 +130,7 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
|
|
|
130
130
|
|
|
131
131
|
func applicationDidFinishLaunching(_ note: Notification) {
|
|
132
132
|
if CommandLine.arguments.contains("--self-test-find") { return selfTestFind() }
|
|
133
|
+
if CommandLine.arguments.contains("--self-test-panels") { return selfTestPanels() }
|
|
133
134
|
buildMenu()
|
|
134
135
|
// esc closes the find bar of the key window, and reaches the page otherwise
|
|
135
136
|
NSEvent.addLocalMonitorForEvents(matching: .keyDown) { e in
|
|
@@ -267,6 +268,61 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
|
|
|
267
268
|
web.loadHTMLString("<p>one needle, two needle</p>", baseURL: nil)
|
|
268
269
|
}
|
|
269
270
|
|
|
271
|
+
/** hears every js panel once it shows: the self test answers it */
|
|
272
|
+
var onPanel: ((NSAlert) -> Void)?
|
|
273
|
+
|
|
274
|
+
// `--self-test-panels`: a page in an offscreen window calls prompt, confirm
|
|
275
|
+
// and alert, answered OK after typing over the prompt's text, then again,
|
|
276
|
+
// answered with esc. What the page got printed, exit 0 only when every
|
|
277
|
+
// answer reached it. Return is checked as OK being the sheet's default
|
|
278
|
+
// button: a synthetic return reaches it only in a key window, and the test
|
|
279
|
+
// must not take the focus
|
|
280
|
+
func selfTestPanels() {
|
|
281
|
+
let web = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
282
|
+
web.navigationDelegate = self
|
|
283
|
+
web.uiDelegate = self
|
|
284
|
+
let win = NSWindow(contentRect: NSRect(x: -4000, y: -4000, width: 600, height: 400), styleMask: [.titled], backing: .buffered, defer: false)
|
|
285
|
+
win.contentView = Pane(web: web)
|
|
286
|
+
win.orderFrontRegardless()
|
|
287
|
+
windows.append(win)
|
|
288
|
+
var accept = true
|
|
289
|
+
var panels = 0
|
|
290
|
+
var returnIsOK = true
|
|
291
|
+
var escHeard = true
|
|
292
|
+
onPanel = { alert in
|
|
293
|
+
panels += 1
|
|
294
|
+
DispatchQueue.main.async {
|
|
295
|
+
if alert.window.defaultButtonCell !== alert.buttons[0].cell { returnIsOK = false }
|
|
296
|
+
if accept {
|
|
297
|
+
// typing replaces the default text only when it is selected in an active editor
|
|
298
|
+
if let field = alert.accessoryView as? NSTextField { (field.currentEditor() as? NSTextView)?.insertText("typed", replacementRange: NSRange(location: NSNotFound, length: 0)) }
|
|
299
|
+
return alert.buttons[0].performClick(nil)
|
|
300
|
+
}
|
|
301
|
+
if alert.buttons.count == 1 { return alert.buttons[0].performClick(nil) }
|
|
302
|
+
let esc = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: alert.window.windowNumber, context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)
|
|
303
|
+
if let esc, alert.window.performKeyEquivalent(with: esc) { return }
|
|
304
|
+
escHeard = false
|
|
305
|
+
alert.buttons[1].performClick(nil)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
let js = "JSON.stringify([prompt('name', 'x'), confirm('ok?'), (alert('hi'), 'alerted')])"
|
|
309
|
+
onLoad = { _ in
|
|
310
|
+
web.evaluateJavaScript(js) { a, _ in
|
|
311
|
+
accept = false
|
|
312
|
+
web.evaluateJavaScript(js) { b, _ in
|
|
313
|
+
let ok = (a as? String) ?? "none", cancel = (b as? String) ?? "none"
|
|
314
|
+
print("panels=\(panels)")
|
|
315
|
+
print("return-is-ok=\(returnIsOK)")
|
|
316
|
+
print("esc-heard=\(escHeard)")
|
|
317
|
+
print("ok=\(ok)")
|
|
318
|
+
print("cancel=\(cancel)")
|
|
319
|
+
exit(panels == 6 && returnIsOK && escHeard && ok == "[\"typed\",true,\"alerted\"]" && cancel == "[null,false,\"alerted\"]" ? 0 : 1)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
web.loadHTMLString("<p>panels</p>", baseURL: nil)
|
|
324
|
+
}
|
|
325
|
+
|
|
270
326
|
func windowWillClose(_ note: Notification) {
|
|
271
327
|
windows.removeAll { $0 === note.object as? NSWindow }
|
|
272
328
|
}
|
|
@@ -293,6 +349,46 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
|
|
|
293
349
|
return nil
|
|
294
350
|
}
|
|
295
351
|
|
|
352
|
+
// alert, confirm and prompt: without these WebKit answers each one at once
|
|
353
|
+
// (no panel, confirm false, prompt null), so a page's dialogs do nothing.
|
|
354
|
+
// WebKit throws when a handler is dropped or called twice: `panel` calls
|
|
355
|
+
// `done` exactly once on every path
|
|
356
|
+
|
|
357
|
+
/** one js panel: a sheet on the page's window, a modal where there is
|
|
358
|
+
* none. Return is OK, esc is Cancel; `done` hears whether OK was pressed */
|
|
359
|
+
func panel(_ web: WKWebView, _ message: String, cancel: Bool, field: NSTextField? = nil, done: @escaping (Bool) -> Void) {
|
|
360
|
+
let alert = NSAlert()
|
|
361
|
+
alert.messageText = message
|
|
362
|
+
alert.addButton(withTitle: "OK").keyEquivalent = "\r"
|
|
363
|
+
if cancel { alert.addButton(withTitle: "Cancel").keyEquivalent = "\u{1b}" }
|
|
364
|
+
if let field {
|
|
365
|
+
alert.accessoryView = field
|
|
366
|
+
alert.window.initialFirstResponder = field
|
|
367
|
+
}
|
|
368
|
+
guard let win = web.window else {
|
|
369
|
+
onPanel?(alert)
|
|
370
|
+
return done(alert.runModal() == .alertFirstButtonReturn)
|
|
371
|
+
}
|
|
372
|
+
alert.beginSheetModal(for: win) { r in done(r == .alertFirstButtonReturn) }
|
|
373
|
+
// first responder selects the whole default text, so typing replaces it
|
|
374
|
+
if let field { alert.window.makeFirstResponder(field) }
|
|
375
|
+
onPanel?(alert)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
func webView(_ web: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
|
|
379
|
+
panel(web, message, cancel: false) { _ in completionHandler() }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
func webView(_ web: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
|
|
383
|
+
panel(web, message, cancel: true) { ok in completionHandler(ok) }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
func webView(_ web: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
|
|
387
|
+
let field = NSTextField(string: defaultText ?? "")
|
|
388
|
+
field.frame = NSRect(x: 0, y: 0, width: 300, height: 24)
|
|
389
|
+
panel(web, prompt, cancel: true, field: field) { ok in completionHandler(ok ? field.stringValue : nil) }
|
|
390
|
+
}
|
|
391
|
+
|
|
296
392
|
func item(_ menu: NSMenu, _ title: String, _ action: Selector, _ key: String, _ mods: NSEvent.ModifierFlags = [.command], tag: Int = 0) {
|
|
297
393
|
let i = menu.addItem(withTitle: title, action: action, keyEquivalent: key)
|
|
298
394
|
i.keyEquivalentModifierMask = mods
|
package/src/home/resources.ts
CHANGED
|
@@ -285,6 +285,9 @@ export class Resource {
|
|
|
285
285
|
* while it answers, else it starts and opens once it does */
|
|
286
286
|
async open(how: OpenHow): Promise<void> {
|
|
287
287
|
if (this.decl.url == null) throw new Error(`${this.decl.id} declares no url to open`)
|
|
288
|
+
// the status is the last tick's: a process that died since would get
|
|
289
|
+
// a window on a dead port and never start
|
|
290
|
+
await this.tick()
|
|
288
291
|
if (this.status === 'up') {
|
|
289
292
|
this.show(how, this.decl.url)
|
|
290
293
|
return
|
package/src/plan/planner.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
//
|
|
12
12
|
// what keeps it off the cpu: a save burst is asked about once the repo is
|
|
13
13
|
// quiet (QUIET_MS, or MAX_WAIT_MS into a burst that never stops), a check a
|
|
14
|
-
// save moved waits COST_FACTOR times its last measured run
|
|
14
|
+
// save moved waits COST_FACTOR times its last measured run and GATED_HOLD_MS
|
|
15
|
+
// after the latest gate someone else started in its repo, and nothing it
|
|
15
16
|
// starts on its own starts while the load per core is above LOAD_MAX.
|
|
16
17
|
import { availableParallelism, loadavg } from 'os'
|
|
17
18
|
import { CATCH_UP_BEAT_MS, resumeCatchUp, type CatchUpStore } from './catch-up.ts'
|
|
@@ -39,6 +40,9 @@ export type PlanSource = {
|
|
|
39
40
|
checks: () => Promise<PlanCheck[]>
|
|
40
41
|
/** every check's last result on record */
|
|
41
42
|
last: () => Record<string, LastResult>
|
|
43
|
+
/** when the latest run someone asked for started here (a terminal, an
|
|
44
|
+
* agent, a click), never one the plan started; null when none did */
|
|
45
|
+
gatedAt?: () => number | null
|
|
42
46
|
/** the checks a save moved: their answer would not replay */
|
|
43
47
|
changed: () => Promise<string[]>
|
|
44
48
|
/** run these checks now, in the background; resolves to the exit code */
|
|
@@ -88,6 +92,11 @@ export const MAX_WAIT_MS = 10 * 60_000
|
|
|
88
92
|
* measured run: a 30s suite at most every 10 minutes, a 200ms lint at once */
|
|
89
93
|
export const COST_FACTOR = 20
|
|
90
94
|
|
|
95
|
+
/** a save moved check waits this long after the latest run someone else
|
|
96
|
+
* started in its repo (a terminal, an agent, a click): whoever gates that
|
|
97
|
+
* tree is still at work, and its own gate measures what it changed */
|
|
98
|
+
export const GATED_HOLD_MS = 10 * 60_000
|
|
99
|
+
|
|
91
100
|
/** above this 1 minute load average per core, the plan starts nothing on its
|
|
92
101
|
* own: every core already has a process waiting for it */
|
|
93
102
|
export const LOAD_MAX = 1
|
|
@@ -112,6 +121,7 @@ type Repo = {
|
|
|
112
121
|
src: PlanSource
|
|
113
122
|
checks: PlanCheck[]
|
|
114
123
|
last: Record<string, LastResult>
|
|
124
|
+
gatedAt: number | null
|
|
115
125
|
lastRead: number
|
|
116
126
|
marks: Map<string, Mark>
|
|
117
127
|
timer: unknown
|
|
@@ -169,7 +179,7 @@ export class Planner {
|
|
|
169
179
|
async add(src: PlanSource): Promise<void> {
|
|
170
180
|
const old = this.repos.get(src.repo)
|
|
171
181
|
if (old?.timer != null) this.timers.clear(old.timer)
|
|
172
|
-
const r: Repo = { src, checks: [], last: {}, lastRead: -Infinity, marks: old?.marks ?? new Map(), timer: null, burstAt: null, settleAt: null, detecting: null, again: false, error: null }
|
|
182
|
+
const r: Repo = { src, checks: [], last: {}, gatedAt: null, lastRead: -Infinity, marks: old?.marks ?? new Map(), timer: null, burstAt: null, settleAt: null, detecting: null, again: false, error: null }
|
|
173
183
|
this.repos.set(src.repo, r)
|
|
174
184
|
await this.reload(src.repo)
|
|
175
185
|
try {
|
|
@@ -223,6 +233,7 @@ export class Planner {
|
|
|
223
233
|
r.lastRead = now
|
|
224
234
|
try {
|
|
225
235
|
r.last = r.src.last()
|
|
236
|
+
r.gatedAt = r.src.gatedAt?.() ?? null
|
|
226
237
|
} catch (e) {
|
|
227
238
|
this.fail(r.src.repo, e)
|
|
228
239
|
}
|
|
@@ -315,7 +326,9 @@ export class Planner {
|
|
|
315
326
|
// a check pays for its own runs: one save cannot run it again
|
|
316
327
|
// before COST_FACTOR times its last measured run has passed
|
|
317
328
|
const spaced = last?.cost != null ? last.cost.at + COST_FACTOR * last.cost.ms : -Infinity
|
|
318
|
-
|
|
329
|
+
// and it waits for whoever gates this tree by hand to stop
|
|
330
|
+
const gated = r.gatedAt != null ? r.gatedAt + GATED_HOLD_MS : -Infinity
|
|
331
|
+
const at = Math.max(m.changedAt, spaced, gated)
|
|
319
332
|
return { ...base, state: at > now ? 'next' : 'due', reason: 'changed', dueAt: at }
|
|
320
333
|
}
|
|
321
334
|
const lastAt = Math.max(last?.at ?? -Infinity, m.attemptAt ?? -Infinity)
|
|
@@ -354,8 +367,12 @@ export class Planner {
|
|
|
354
367
|
held(): string[] {
|
|
355
368
|
const now = this.now()
|
|
356
369
|
const out: string[] = []
|
|
357
|
-
for (const r of this.repos.values())
|
|
370
|
+
for (const r of this.repos.values()) {
|
|
358
371
|
if (r.settleAt != null) out.push(`settling: ${r.src.repo}, ${Math.max(0, Math.ceil((r.settleAt - now) / 1_000))}s`)
|
|
372
|
+
const until = r.gatedAt == null ? null : r.gatedAt + GATED_HOLD_MS
|
|
373
|
+
if (until != null && until > now && [...r.marks.values()].some((m) => m.changedAt != null))
|
|
374
|
+
out.push(`deferred: ${r.src.repo}, a gate someone started there ${Math.floor((now - r.gatedAt!) / 60_000)}m ago, so what a save moved waits ${Math.ceil((until - now) / 60_000)}m`)
|
|
375
|
+
}
|
|
359
376
|
if (this.holdReason != null) out.push(this.holdReason)
|
|
360
377
|
return out
|
|
361
378
|
}
|
package/src/plan/repo-source.ts
CHANGED
|
@@ -82,12 +82,15 @@ export function readLastCosts(path: string): Record<string, { at: number; ms: nu
|
|
|
82
82
|
return out
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/** when the latest run on record started,
|
|
86
|
-
|
|
85
|
+
/** when the latest run on record started, or null. `asked`: only a run
|
|
86
|
+
* someone asked for (a terminal, an agent, a click), never one the plan
|
|
87
|
+
* started on its own */
|
|
88
|
+
export function lastStarted(path: string, o: { asked?: boolean } = {}): number | null {
|
|
87
89
|
const opened = openDb(path, { create: false })
|
|
88
90
|
if (opened == null) return null
|
|
89
91
|
try {
|
|
90
|
-
const
|
|
92
|
+
const sql = o.asked === true ? "select max(started) as at from invocations where by != 'schedule'" : 'select max(started) as at from invocations'
|
|
93
|
+
const r = opened.db.query(sql).get() as { at: number | null } | null
|
|
91
94
|
return r?.at == null ? null : Number(r.at)
|
|
92
95
|
} catch {
|
|
93
96
|
return null
|
|
@@ -144,6 +147,7 @@ export function repoSource(d: RepoSourceDeps): PlanSource {
|
|
|
144
147
|
for (const [id, cost] of Object.entries(costs)) if (all[id] != null) all[id] = { ...all[id]!, cost }
|
|
145
148
|
return all
|
|
146
149
|
},
|
|
150
|
+
gatedAt: () => (d.record === '' ? null : lastStarted(d.record, { asked: true })),
|
|
147
151
|
changed: async () => {
|
|
148
152
|
const decls = rows.map((r) => r.decl)
|
|
149
153
|
if (decls.length === 0) return []
|
|
@@ -12,7 +12,7 @@ import { runCi } from '../core/run-ci.ts'
|
|
|
12
12
|
import { extractChangelogSection } from './changelog.ts'
|
|
13
13
|
import { deriveRemote, dirtyPaths, git, remoteTagState, tagExistsLocally, type RemoteTagState } from './git-facts.ts'
|
|
14
14
|
import { releaseScope } from './release-scope.ts'
|
|
15
|
-
import {
|
|
15
|
+
import { publishCommand, unitBlockers, type Publisher } from './unit-gate.ts'
|
|
16
16
|
import { formatUnitError, resolveUnits, unitById, unknownUnit, type ReleaseUnit } from './units.ts'
|
|
17
17
|
import type { RepoConfig } from '../repo-config.ts'
|
|
18
18
|
import { nestedIn, nestedRefusal } from '../core/nesting.ts'
|
|
@@ -37,6 +37,10 @@ export type ReleaseConfig = {
|
|
|
37
37
|
* Absent = rely on ambient npm auth. Never put a token in config.
|
|
38
38
|
* Repo-wide: one account publishes every unit. */
|
|
39
39
|
npmTokenCommand?: string[]
|
|
40
|
+
/** the tool that packs and publishes every npm unit (default 'npm'). A
|
|
41
|
+
* repo that tests its tarball with `bun pm pack` publishes with 'bun', so
|
|
42
|
+
* the packer it tests is the packer that ships. */
|
|
43
|
+
publisher?: Publisher
|
|
40
44
|
/** cut a GitHub release for the ROOT unit with `gh` (default true) */
|
|
41
45
|
githubRelease?: boolean
|
|
42
46
|
/** make a NON-blocking check failure block the release too. Blocking
|
|
@@ -301,18 +305,23 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
|
|
|
301
305
|
if (wantNpm && !alreadyPublished) {
|
|
302
306
|
const env: Record<string, string> = { ...process.env as Record<string, string> }
|
|
303
307
|
const argv = rc.npmTokenCommand
|
|
308
|
+
const publisher = rc.publisher ?? 'npm'
|
|
304
309
|
let npmrc: string | null = null
|
|
310
|
+
let token: string | null = null
|
|
305
311
|
if (argv != null && argv[0] != null) {
|
|
306
312
|
const t = Bun.spawnSync(argv)
|
|
307
|
-
|
|
313
|
+
token = t.exitCode === 0 ? t.stdout.toString().trim() : ''
|
|
308
314
|
if (token === '') { print(`FAIL: release.npmTokenCommand produced no token (exit ${t.exitCode})`); return 1 }
|
|
309
|
-
// token via a throwaway userconfig
|
|
310
|
-
// committed .npmrc, never a line in a log
|
|
311
|
-
|
|
312
|
-
|
|
315
|
+
// token via a throwaway userconfig (npm) or the env (bun),
|
|
316
|
+
// never argv, never a committed .npmrc, never a line in a log
|
|
317
|
+
if (publisher === 'npm') {
|
|
318
|
+
npmrc = join(work, 'npmrc')
|
|
319
|
+
writeFileSync(npmrc, `//registry.npmjs.org/:_authToken=${token}\n`, { mode: 0o600 })
|
|
320
|
+
}
|
|
313
321
|
}
|
|
314
|
-
const
|
|
315
|
-
|
|
322
|
+
const cmd = publishCommand({ publisher, npmrc, token })
|
|
323
|
+
const p = Bun.spawnSync(cmd.argv, { cwd: unit.dir, env: { ...env, ...cmd.env }, stdout: 'inherit', stderr: 'inherit' })
|
|
324
|
+
if (p.exitCode !== 0) { print(`FAIL: ${publisher} publish failed for ${unit.id} (exit ${p.exitCode})`); return 1 }
|
|
316
325
|
print(`ok: published ${name}@${version}`)
|
|
317
326
|
}
|
|
318
327
|
|
package/src/release/unit-gate.ts
CHANGED
|
@@ -9,11 +9,18 @@ import { dirtyPaths } from './git-facts.ts'
|
|
|
9
9
|
import type { ReleaseUnit } from './units.ts'
|
|
10
10
|
import type { RepoConfig } from '../repo-config.ts'
|
|
11
11
|
|
|
12
|
-
/** the
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
/** the tool that packs and publishes an npm unit (`release.publisher`) */
|
|
13
|
+
export const PUBLISHERS = ['npm', 'bun'] as const
|
|
14
|
+
export type Publisher = (typeof PUBLISHERS)[number]
|
|
15
|
+
|
|
16
|
+
/** the publish argv and the env its token rides in. `--access public` is not
|
|
17
|
+
* a default: npm restricts a scoped package unless it is asked, and a
|
|
18
|
+
* restricted publish looks successful right up until a consumer cannot
|
|
19
|
+
* install it. npm reads the token from a throwaway userconfig; `bun publish`
|
|
20
|
+
* has no `--userconfig` and reads NPM_CONFIG_TOKEN instead. */
|
|
21
|
+
export function publishCommand(o: { publisher?: Publisher; npmrc?: string | null; token?: string | null } = {}): { argv: string[]; env: Record<string, string> } {
|
|
22
|
+
if (o.publisher === 'bun') return { argv: ['bun', 'publish', '--access', 'public'], env: o.token != null ? { NPM_CONFIG_TOKEN: o.token } : {} }
|
|
23
|
+
return { argv: ['npm', 'publish', '--access', 'public', ...(o.npmrc != null ? ['--userconfig', o.npmrc] : [])], env: {} }
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
/** everything that must hold for this unit, checked before anything is
|
package/src/repo-config.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { Rule } from './check.ts'
|
|
|
25
25
|
import { leakAllowProblems } from './hooks/leak-guard.ts'
|
|
26
26
|
import type { HooksConfig } from './hooks/run-hooks.ts'
|
|
27
27
|
import type { ReleaseConfig } from './release/run-release.ts'
|
|
28
|
+
import { PUBLISHERS } from './release/unit-gate.ts'
|
|
28
29
|
import { TEST_ONLY_UNIT_KEYS, UNIT_DECL_KEYS, type UnitDecl } from './release/unit-resolve.ts'
|
|
29
30
|
import { TASK_DEF_KEYS, type TaskDef } from './check.ts'
|
|
30
31
|
import { testsConfigProblems, type TestsConfig } from './suite/judge-suites.ts'
|
|
@@ -41,6 +42,10 @@ export type ServeConfig = {
|
|
|
41
42
|
* its own port — house range 5800+, default = the hub port plus 1000
|
|
42
43
|
* (build/comfyui.md owns that surface) */
|
|
43
44
|
comfyPort?: number
|
|
45
|
+
/** false hides the asset kit from the hub page (no asset rows, an empty
|
|
46
|
+
* studio) and from `shipkit needs`, for a repo whose own tool mounts
|
|
47
|
+
* `studioRoutes` and owns the picking. Default true */
|
|
48
|
+
assets?: boolean
|
|
44
49
|
/** custom wiki-graph provider for repos with their own link semantics.
|
|
45
50
|
* Default: a generic md scan of the repo root. */
|
|
46
51
|
wikiGraph?: {
|
|
@@ -320,6 +325,8 @@ export function defineRepo(c: RepoConfig): RepoConfig {
|
|
|
320
325
|
for (const k of Object.keys(c))
|
|
321
326
|
if (!KNOWN_KEYS.has(k))
|
|
322
327
|
throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : k === 'home' ? ` — home.resources was removed; declare each one in a <name>.shipkit.ts file as \`export default globalProcess({...})\`` : ''}`)
|
|
328
|
+
if (c.release?.publisher != null && !(PUBLISHERS as readonly string[]).includes(c.release.publisher))
|
|
329
|
+
throw new Error(`defineRepo: release.publisher '${String(c.release.publisher)}' is not a publisher (${PUBLISHERS.join(', ')})`)
|
|
323
330
|
// a typo'd unit key would silently drop a target and release the wrong shape
|
|
324
331
|
for (const u of c.units ?? []) {
|
|
325
332
|
if (typeof u.dir !== 'string' || u.dir === '') throw new Error(`defineRepo: every units[] entry needs a 'dir'`)
|
package/src/serve/hub-runtime.ts
CHANGED
|
@@ -158,9 +158,10 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
158
158
|
graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), lint: { root: agentRoot, index: c.serve?.agentIndex } })
|
|
159
159
|
// ONE page for every repo: the studio ALWAYS
|
|
160
160
|
// mounts — kit-less repos get an empty media section, never a second
|
|
161
|
-
// page. A broken kit degrades to the same empty kit, loudly
|
|
161
|
+
// page. A broken kit degrades to the same empty kit, loudly, and a repo
|
|
162
|
+
// whose own tool owns the picking turns the kit off here
|
|
162
163
|
let kit: ResolvedKit | null = null
|
|
163
|
-
if (existsSync(join(root, '.shipkit/assets/kit.ts'))) {
|
|
164
|
+
if (c.serve?.assets !== false && existsSync(join(root, '.shipkit/assets/kit.ts'))) {
|
|
164
165
|
try {
|
|
165
166
|
kit = await loadKit(root)
|
|
166
167
|
} catch (e) {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
10
10
|
import { basename, dirname, join, resolve, sep } from 'path'
|
|
11
11
|
import { fileURLToPath } from 'url'
|
|
12
|
-
import { gitRoot } from '../core/git-root.ts'
|
|
12
|
+
import { gitRoot, isLinkedWorktree } from '../core/git-root.ts'
|
|
13
13
|
import { DB_FILE } from '../ledger/db.ts'
|
|
14
14
|
import { defaultHubPort } from './hub-port.ts'
|
|
15
15
|
import { LAYOUT } from '../core/layout.ts'
|
|
@@ -52,7 +52,9 @@ export const ICON_FILES = ['.shipkit/identity/icon.png', 'public/favicon.png', '
|
|
|
52
52
|
|
|
53
53
|
export const ICON_TYPES: Record<string, string> = { png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon' }
|
|
54
54
|
|
|
55
|
-
/** every direct child of `parent` that includes `index`, sorted by name
|
|
55
|
+
/** every direct child of `parent` that includes `index`, sorted by name. A
|
|
56
|
+
* linked worktree of one is that repo checked out again, never a repo of its
|
|
57
|
+
* own: its processes would collide with the main checkout's ids and ports */
|
|
56
58
|
export function wiredRepos(parent: string, index: string, home: string): WiredRepo[] {
|
|
57
59
|
let names: string[]
|
|
58
60
|
try {
|
|
@@ -65,6 +67,7 @@ export function wiredRepos(parent: string, index: string, home: string): WiredRe
|
|
|
65
67
|
if (name.startsWith('.')) continue
|
|
66
68
|
const path = join(parent, name)
|
|
67
69
|
if (!includeTargets(path, home).includes(index)) continue
|
|
70
|
+
if (isLinkedWorktree(path)) continue
|
|
68
71
|
out.push({ name, path, icon: ICON_FILES.find((f) => existsSync(join(path, f))) ?? null })
|
|
69
72
|
}
|
|
70
73
|
return out
|
package/src/serve/run-lanes.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// - a run that writes the tree (a repair pass) waits for every run ahead of
|
|
8
8
|
// it, and every later run waits for it
|
|
9
9
|
// - every other run starts at once: the machine slot pool each check body
|
|
10
|
-
// takes bounds the CPU, never this queue
|
|
10
|
+
// takes bounds the CPU, never this queue. It admits fewer slots on a
|
|
11
|
+
// loaded machine (core/slots.ts), so a run past it waits and says why
|
|
11
12
|
//
|
|
12
13
|
// the one constraint a reader breaks first: the stamp is taken when a run
|
|
13
14
|
// STARTS, before its body reads anything, so a tree that moves during the run
|
|
@@ -15,6 +16,7 @@
|
|
|
15
16
|
import { createHash } from 'crypto'
|
|
16
17
|
import { statSync } from 'fs'
|
|
17
18
|
import { join } from 'path'
|
|
19
|
+
import { fixPassRuns } from '../core/fix-scope.ts'
|
|
18
20
|
import { gitBin } from '../core/git-bin.ts'
|
|
19
21
|
|
|
20
22
|
export type LaneAsk = {
|
|
@@ -106,8 +108,10 @@ export function runKey(kind: string, opts: Record<string, unknown>): string {
|
|
|
106
108
|
return `${kind} ${JSON.stringify(sorted)}`
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
/** a run
|
|
110
|
-
|
|
111
|
+
/** a run whose repair pass runs (core/fix-scope.ts): the structural fixers
|
|
112
|
+
* and the hooks only ever run inside it */
|
|
113
|
+
export const writesTree = (opts: Record<string, unknown>): boolean =>
|
|
114
|
+
fixPassRuns({ fix: typeof opts.fix === 'boolean' ? opts.fix : undefined, onlyIds: Array.isArray(opts.onlyIds) ? (opts.onlyIds as string[]) : undefined })
|
|
111
115
|
|
|
112
116
|
/** HEAD, what git status lists, and the stat of each listed path, hashed:
|
|
113
117
|
* equal stamps mean no tracked or untracked file moved in between. Null
|
package/src/smoke/run-smoke.ts
CHANGED
|
@@ -27,6 +27,10 @@ export type SmokeRoute = {
|
|
|
27
27
|
* match's href after this route and judges the landed page as its own
|
|
28
28
|
* row. How a detail page with a real id gets visited without knowing one. */
|
|
29
29
|
open?: string
|
|
30
|
+
/** the visible text a settled route needs before it counts as rendered,
|
|
31
|
+
* default 40. A canvas app (a game) draws everything into one canvas and
|
|
32
|
+
* shows no text: `{ path: '/', waitFor: 'canvas', minChars: 0 }`. */
|
|
33
|
+
minChars?: number
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export type SmokeLogin = {
|
|
@@ -63,8 +67,9 @@ export type SmokeConfig = {
|
|
|
63
67
|
const SMOKE_KEYS = new Set(['base', 'routes', 'login', 'paceMs', 'shots'])
|
|
64
68
|
const LOGIN_KEYS = new Set(['path', 'email', 'emailSecret', 'passwordSecret', 'form'])
|
|
65
69
|
const FORM_KEYS = new Set(['email', 'password', 'submit'])
|
|
66
|
-
const ROUTE_KEYS = new Set(['path', 'waitFor', 'open'])
|
|
70
|
+
const ROUTE_KEYS = new Set(['path', 'waitFor', 'open', 'minChars'])
|
|
67
71
|
export const DEFAULT_LOGIN_PATH = '/login'
|
|
72
|
+
export const DEFAULT_MIN_CHARS = 40
|
|
68
73
|
|
|
69
74
|
export const DEFAULT_PACE_MS = 3000
|
|
70
75
|
export const DEFAULT_SHOTS = `${LAYOUT.tmp}/smoke`
|
|
@@ -86,6 +91,8 @@ export function validateSmokeConfig(c: unknown): SmokeConfig {
|
|
|
86
91
|
const v = (r as Record<string, unknown>)[k]
|
|
87
92
|
if (v != null && (typeof v !== 'string' || v === '')) throw new Error(`defineRepo: smoke.routes['${path}'].${k} must be a non-empty selector`)
|
|
88
93
|
}
|
|
94
|
+
const min = (r as Record<string, unknown>)['minChars']
|
|
95
|
+
if (min != null && (typeof min !== 'number' || !Number.isInteger(min) || min < 0)) throw new Error(`defineRepo: smoke.routes['${path}'].minChars must be a whole number of characters, 0 or more`)
|
|
89
96
|
}
|
|
90
97
|
}
|
|
91
98
|
if (s['login'] != null) {
|
|
@@ -152,8 +159,8 @@ export type SmokeReport = {
|
|
|
152
159
|
* nothing threw, an empty `waitFor` on a listing is exactly how a broken
|
|
153
160
|
* migration looks, and a page that scrolls sideways is a layout defect only
|
|
154
161
|
* a real browser can see (happy-dom does no layout). */
|
|
155
|
-
export function judgeRoute(r: { route: string; chars: number; matches: number; problems: number; waitFor?: string; overflowPx: number }): RouteReport {
|
|
156
|
-
const empty = r.chars <
|
|
162
|
+
export function judgeRoute(r: { route: string; chars: number; matches: number; problems: number; waitFor?: string; minChars?: number; overflowPx: number }): RouteReport {
|
|
163
|
+
const empty = r.chars < (r.minChars ?? DEFAULT_MIN_CHARS)
|
|
157
164
|
const noMatch = r.waitFor != null && r.matches === 0
|
|
158
165
|
const sideways = r.overflowPx > 1
|
|
159
166
|
const note = empty ? 'EMPTY PAGE' : noMatch ? `NO MATCH FOR ${r.waitFor}` : sideways ? `PAGE SCROLLS SIDEWAYS +${r.overflowPx}px` : r.problems > 0 ? `${r.problems} problem(s)` : ''
|
|
@@ -368,7 +375,7 @@ export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRun
|
|
|
368
375
|
}
|
|
369
376
|
|
|
370
377
|
/** visit one url, settle it, judge it, screenshot it */
|
|
371
|
-
const visit = async (label: string, url: string, waitFor?: string): Promise<RouteReport> => {
|
|
378
|
+
const visit = async (label: string, url: string, waitFor?: string, minChars?: number): Promise<RouteReport> => {
|
|
372
379
|
// let the previous page's requests drain out of the concurrency
|
|
373
380
|
// window BEFORE the label moves, or its late failures land on this row
|
|
374
381
|
if (paceMs > 0) await page.waitForTimeout(paceMs)
|
|
@@ -387,11 +394,11 @@ export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRun
|
|
|
387
394
|
return Math.max(de.scrollWidth - de.clientWidth, doc.body.scrollWidth - doc.body.clientWidth)
|
|
388
395
|
}).catch(() => 0)
|
|
389
396
|
await page.screenshot({ path: join(shots, `${routeSlug(label)}.png`), fullPage: true })
|
|
390
|
-
return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, overflowPx })
|
|
397
|
+
return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, minChars, overflowPx })
|
|
391
398
|
}
|
|
392
399
|
|
|
393
400
|
for (const route of routes) {
|
|
394
|
-
reports.push(await visit(route.path, route.path, route.waitFor))
|
|
401
|
+
reports.push(await visit(route.path, route.path, route.waitFor, route.minChars))
|
|
395
402
|
if (route.open == null) continue
|
|
396
403
|
// a detail page with a real id: the first link the selector matches
|
|
397
404
|
const href = await page.locator(route.open).first().getAttribute('href').catch(() => null)
|