@erclx/canon 5.73.0 → 5.75.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 +41 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/references/adopted.md +5 -3
- package/claude/skills/build-in-slices/references/adopted.md +4 -2
- package/claude/skills/code-craft/references/adopted.md +9 -7
- package/claude/skills/codebase-layout/references/adopted.md +8 -6
- package/claude/skills/create-rule/SKILL.md +5 -1
- package/claude/skills/create-rule/references/ledger.md +73 -0
- package/claude/skills/create-rule/references/rule.md +1 -1
- package/claude/skills/create-skill/references/skill-practice.md +6 -1
- package/claude/skills/deprecation-migration/references/adopted.md +5 -3
- package/claude/skills/design-pick/references/board.md +2 -2
- package/claude/skills/plan-feature/SKILL.md +2 -1
- package/claude/skills/plan-groundwork/SKILL.md +2 -0
- package/claude/skills/pr-evidence/REQUIREMENT.md +1 -0
- package/claude/skills/pr-evidence/SKILL.md +2 -0
- package/claude/skills/pr-evidence/references/evidence.md +11 -4
- package/claude/skills/pr-evidence/references/reel.md +108 -0
- package/claude/skills/proposal-critique/REQUIREMENT.md +34 -0
- package/claude/skills/proposal-critique/SKILL.md +126 -0
- package/claude/skills/proposal-critique/references/adopted.md +44 -0
- package/claude/skills/review-craft/references/adopted.md +4 -4
- package/claude/skills/search-craft/references/adopted.md +9 -7
- package/claude/skills/systematic-debugging/references/adopted.md +4 -2
- package/claude/skills/teach-craft/references/adopted.md +8 -6
- package/claude/skills/test-craft/references/adopted.md +14 -12
- package/claude/skills/test-first/SKILL.md +1 -1
- package/claude/skills/test-first/references/adopted.md +9 -5
- package/claude/skills/ui-film/REQUIREMENT.md +4 -5
- package/claude/skills/ui-film/SKILL.md +21 -19
- package/claude/skills/ui-film/references/compose-craft.md +2 -2
- package/claude/skills/ui-film/references/handoff.md +2 -7
- package/claude/skills/write-human/references/adopted.md +8 -3
- package/docs/agents/audits.md +24 -24
- package/docs/agents/migrate.md +10 -10
- package/docs/migrations/by-verb.md +2 -0
- package/docs/target/index.md +1 -0
- package/docs/target/outside-tools.md +39 -0
- package/docs/workflow/skill-map.md +1 -0
- package/governance/detect.toml +4 -0
- package/governance/ledger/claude/rule.md +16 -0
- package/governance/ledger/skills/build-in-slices/SKILL.md +55 -0
- package/governance/ledger/skills/proposal-critique/SKILL.md +66 -0
- package/governance/ledger/skills/search-craft/SKILL.md +54 -0
- package/governance/ledger/skills/systematic-debugging/SKILL.md +64 -0
- package/governance/ledger/skills/test-first/SKILL.md +28 -0
- package/governance/ledger/skills/write-human/SKILL.md +62 -0
- package/governance/ledger/skills/write-human/density.md +36 -0
- package/governance/ledger/skills/write-human/machine-tells.md +30 -0
- package/governance/rules/claude/rule.md +13 -0
- package/package.json +1 -1
- package/scripts/ci/docs-only.sh +24 -0
- package/scripts/ci/refresh-frames-pr.sh +11 -12
- package/src/audits/catalog.ts +7 -2
- package/src/capture/stamp.ts +3 -5
- package/src/claude/cases/misc.ts +14 -0
- package/src/claude/skills-audit.ts +154 -8
- package/src/cli/help.ts +1 -1
- package/src/commands/claude.ts +6 -2
- package/src/commands/gov.ts +12 -7
- package/src/commands/migrate.ts +6 -2
- package/src/commands/pr.ts +110 -13
- package/src/commands/take.ts +36 -7
- package/src/counts/catalogs.ts +3 -5
- package/src/gate/measures.ts +2 -145
- package/src/gate/sequencer.ts +7 -1
- package/src/gate/stages.ts +36 -47
- package/src/gov/audit.ts +59 -13
- package/src/markdown/scope.ts +4 -0
- package/src/migrate/session-defaults.ts +114 -4
- package/src/pr/checks.ts +75 -3
- package/src/pr/evidence.ts +62 -1
- package/src/pr/lift.ts +23 -2
- package/src/pr/merge.ts +13 -3
- package/src/take/encode.ts +9 -1
- package/src/take/film.ts +17 -2
- package/src/take/fixtures/busy.html +107 -0
- package/src/take/perform.ts +99 -25
- package/src/take/screencast.ts +4 -3
- package/src/take/stepper.ts +33 -20
- package/src/targets/pulls.ts +42 -0
- package/tooling/claude/reference.md +4 -2
- package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
- package/tooling/claude/seeds/.claude/settings.json +8 -0
- package/claude/skills/ui-film/references/pr-clip.md +0 -108
- package/scripts/core/regen-hero.ts +0 -113
- package/src/capture/frame-values.ts +0 -326
package/src/pr/lift.ts
CHANGED
|
@@ -83,8 +83,19 @@ export interface EvidenceGate {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
export interface ChecksGate {
|
|
86
|
+
/** The state over the checks the base requires, or every check when none were read. */
|
|
86
87
|
readonly state: ChecksState
|
|
87
88
|
readonly isConflicted: boolean
|
|
89
|
+
/** Failing checks outside the required set, which are reported and never gate. */
|
|
90
|
+
readonly advisory?: readonly string[]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The advisory field a record carries, absent when no advisory check failed. */
|
|
94
|
+
export function advisoryOf(reading: ChecksGate): {
|
|
95
|
+
advisory?: readonly string[]
|
|
96
|
+
} {
|
|
97
|
+
const advisory = reading.advisory ?? []
|
|
98
|
+
return advisory.length > 0 ? { advisory } : {}
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
/**
|
|
@@ -129,6 +140,8 @@ export interface LiftRecord {
|
|
|
129
140
|
readonly carriedFrom?: string
|
|
130
141
|
readonly paintedPaths?: readonly string[]
|
|
131
142
|
readonly drivenPaths?: readonly string[]
|
|
143
|
+
/** Failing checks the base does not require, named so a reader sees them before merging. */
|
|
144
|
+
readonly advisory?: readonly string[]
|
|
132
145
|
readonly isLifted: boolean
|
|
133
146
|
/** Whether the caller can treat the pull request as ready afterwards. */
|
|
134
147
|
readonly isOk: boolean
|
|
@@ -247,7 +260,11 @@ function standingGate(standing: Standing): GateResult {
|
|
|
247
260
|
}
|
|
248
261
|
|
|
249
262
|
type CiOutcome =
|
|
250
|
-
| {
|
|
263
|
+
| {
|
|
264
|
+
readonly kind: 'passing'
|
|
265
|
+
readonly waited: number
|
|
266
|
+
readonly reading: ChecksGate
|
|
267
|
+
}
|
|
251
268
|
| {
|
|
252
269
|
readonly kind: 'refused'
|
|
253
270
|
readonly reason: LiftRefusal
|
|
@@ -274,7 +291,9 @@ async function waitForChecks(
|
|
|
274
291
|
if (reading === undefined) {
|
|
275
292
|
return { kind: 'refused', reason: 'gh-failed', waited }
|
|
276
293
|
}
|
|
277
|
-
if (reading.state === 'passing')
|
|
294
|
+
if (reading.state === 'passing') {
|
|
295
|
+
return { kind: 'passing', waited, reading }
|
|
296
|
+
}
|
|
278
297
|
if (reading.state === 'failing') {
|
|
279
298
|
return { kind: 'refused', reason: 'ci-failing', waited }
|
|
280
299
|
}
|
|
@@ -422,6 +441,7 @@ export async function lift(
|
|
|
422
441
|
gates: all,
|
|
423
442
|
waitedSeconds: 0,
|
|
424
443
|
...carried(all),
|
|
444
|
+
...advisoryOf(reading),
|
|
425
445
|
}
|
|
426
446
|
}
|
|
427
447
|
|
|
@@ -485,6 +505,7 @@ export async function lift(
|
|
|
485
505
|
gates: cleared,
|
|
486
506
|
waitedSeconds: ci.waited,
|
|
487
507
|
...carried(cleared),
|
|
508
|
+
...advisoryOf(ci.reading),
|
|
488
509
|
}
|
|
489
510
|
}
|
|
490
511
|
|
package/src/pr/merge.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { advisoryOf, type ChecksGate } from '@/pr/lift'
|
|
2
2
|
import { readSection } from '@/pr/paths'
|
|
3
3
|
import { UI_PASS_MARKER } from '@/pr/tick'
|
|
4
4
|
|
|
@@ -67,6 +67,8 @@ export interface MergeRecord {
|
|
|
67
67
|
readonly description?: string
|
|
68
68
|
/** The squash commit, present only on a merge. */
|
|
69
69
|
readonly sha?: string
|
|
70
|
+
/** Failing checks the base does not require, which the merge went past. */
|
|
71
|
+
readonly advisory?: readonly string[]
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
export type Composed =
|
|
@@ -230,8 +232,15 @@ export async function merge(
|
|
|
230
232
|
)
|
|
231
233
|
}
|
|
232
234
|
|
|
233
|
-
if (opts.isDryRun)
|
|
234
|
-
return {
|
|
235
|
+
if (opts.isDryRun) {
|
|
236
|
+
return {
|
|
237
|
+
reason: 'dry-run',
|
|
238
|
+
isOk: true,
|
|
239
|
+
number,
|
|
240
|
+
...message,
|
|
241
|
+
...advisoryOf(checks),
|
|
242
|
+
}
|
|
243
|
+
}
|
|
235
244
|
|
|
236
245
|
const outcome = await api.putMerge({ ...message, sha: pull.head })
|
|
237
246
|
if (outcome.kind === 'merged') {
|
|
@@ -241,6 +250,7 @@ export async function merge(
|
|
|
241
250
|
number,
|
|
242
251
|
...message,
|
|
243
252
|
sha: outcome.sha,
|
|
253
|
+
...advisoryOf(checks),
|
|
244
254
|
}
|
|
245
255
|
}
|
|
246
256
|
return refused(
|
package/src/take/encode.ts
CHANGED
|
@@ -97,11 +97,17 @@ export function openFrameEncoder(out: string, fps: number): FrameEncoder {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Re-encodes a recorded clip to the same H.264 output at a constant `fps`, cut
|
|
102
|
+
* at `ms`. Playwright's recorder pads its webm past the last frame by at least
|
|
103
|
+
* a second, so the cut is what ends the clip where the take's clock stopped.
|
|
104
|
+
* A shorter input stays as it is, since `-t` never extends one.
|
|
105
|
+
*/
|
|
101
106
|
export async function transcode(
|
|
102
107
|
input: string,
|
|
103
108
|
out: string,
|
|
104
109
|
fps: number,
|
|
110
|
+
ms: number,
|
|
105
111
|
): Promise<void> {
|
|
106
112
|
const proc = spawn(
|
|
107
113
|
'ffmpeg',
|
|
@@ -111,6 +117,8 @@ export async function transcode(
|
|
|
111
117
|
'error',
|
|
112
118
|
'-i',
|
|
113
119
|
input,
|
|
120
|
+
'-t',
|
|
121
|
+
(ms / 1000).toFixed(3),
|
|
114
122
|
'-r',
|
|
115
123
|
String(fps),
|
|
116
124
|
...OUTPUT,
|
package/src/take/film.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type TakeLog,
|
|
12
12
|
VIEWPORT,
|
|
13
13
|
} from '@/take/log'
|
|
14
|
-
import { TargetMissing } from '@/take/perform'
|
|
14
|
+
import { TargetMissing, TargetOffscreen, TargetRefused } from '@/take/perform'
|
|
15
15
|
import type { Beat, Route, TakePlan } from '@/take/plan'
|
|
16
16
|
import { filmScreencast } from '@/take/screencast'
|
|
17
17
|
import { ShellMissing, findShell } from '@/take/shell'
|
|
@@ -30,6 +30,8 @@ export type TakeRefusal =
|
|
|
30
30
|
| 'browser-missing'
|
|
31
31
|
| 'server-unreachable'
|
|
32
32
|
| 'target-missing'
|
|
33
|
+
| 'target-offscreen'
|
|
34
|
+
| 'over-cap'
|
|
33
35
|
| 'take-failed'
|
|
34
36
|
|
|
35
37
|
export interface FilmedBeat {
|
|
@@ -74,6 +76,8 @@ export interface TakeRequest {
|
|
|
74
76
|
readonly env?: NodeJS.ProcessEnv
|
|
75
77
|
/** How long one stepped frame may take before its beat falls back to a screencast. */
|
|
76
78
|
readonly stallMs?: number
|
|
79
|
+
/** The filmed seconds the beats may sum to, past which the take refuses. */
|
|
80
|
+
readonly maxSeconds?: number
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
const REACH_TIMEOUT_MS = 5000
|
|
@@ -127,7 +131,7 @@ export async function take(request: TakeRequest): Promise<TakeResult> {
|
|
|
127
131
|
)
|
|
128
132
|
} catch (error) {
|
|
129
133
|
const cause = error instanceof FellBack ? error.error : error
|
|
130
|
-
const shot = cause instanceof
|
|
134
|
+
const shot = cause instanceof TargetRefused ? cause.still : undefined
|
|
131
135
|
const still = shot ? join(dir, 'still.jpg') : undefined
|
|
132
136
|
if (shot && still) writeFileSync(join(out, still), shot)
|
|
133
137
|
return {
|
|
@@ -136,6 +140,16 @@ export async function take(request: TakeRequest): Promise<TakeResult> {
|
|
|
136
140
|
...(still ? { still } : {}),
|
|
137
141
|
}
|
|
138
142
|
}
|
|
143
|
+
// Checked after each beat rather than once at the end, so a take refused
|
|
144
|
+
// on its first beat films nothing past it.
|
|
145
|
+
const frames = beats.reduce((sum, filmed) => sum + filmed.frames, 0)
|
|
146
|
+
if (request.maxSeconds !== undefined && frames > request.maxSeconds * FPS) {
|
|
147
|
+
return failed(
|
|
148
|
+
'over-cap',
|
|
149
|
+
`the beats through ${beat.name} run ${(frames / FPS).toFixed(2)} s, past the ${request.maxSeconds} s cap`,
|
|
150
|
+
beat.name,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
139
153
|
}
|
|
140
154
|
|
|
141
155
|
writeFileSync(
|
|
@@ -231,6 +245,7 @@ class FellBack extends Error {
|
|
|
231
245
|
|
|
232
246
|
function classify(error: unknown): TakeRefusal {
|
|
233
247
|
if (error instanceof TargetMissing) return 'target-missing'
|
|
248
|
+
if (error instanceof TargetOffscreen) return 'target-offscreen'
|
|
234
249
|
if (error instanceof ShellMissing || isBrowserMissing(error)) {
|
|
235
250
|
return 'browser-missing'
|
|
236
251
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>Busy</title>
|
|
6
|
+
<!--
|
|
7
|
+
A hero canvas that redraws every frame and spends busy=<ms> of main
|
|
8
|
+
thread work on each, so a frame takes about that long to render. A card
|
|
9
|
+
sits about 2900px down, below the window, and the rail link scrolls to it.
|
|
10
|
+
With timed=1 each frame spins on performance.now() instead, as the
|
|
11
|
+
groundwork page did, which never ends once the stepper pauses virtual
|
|
12
|
+
time.
|
|
13
|
+
-->
|
|
14
|
+
<style>
|
|
15
|
+
html {
|
|
16
|
+
scroll-behavior: smooth;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
body {
|
|
20
|
+
margin: 0;
|
|
21
|
+
font-family: sans-serif;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
nav {
|
|
25
|
+
position: fixed;
|
|
26
|
+
top: 10px;
|
|
27
|
+
right: 10px;
|
|
28
|
+
z-index: 2;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
nav a {
|
|
32
|
+
display: block;
|
|
33
|
+
padding: 12px 20px;
|
|
34
|
+
background: #fff;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
canvas {
|
|
38
|
+
display: block;
|
|
39
|
+
width: 100vw;
|
|
40
|
+
height: 100vh;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
section {
|
|
44
|
+
height: 900px;
|
|
45
|
+
border-top: 1px solid #ccc;
|
|
46
|
+
padding: 40px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.card {
|
|
50
|
+
width: 400px;
|
|
51
|
+
height: 240px;
|
|
52
|
+
background: #ddd;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.card:hover {
|
|
56
|
+
background: #f60;
|
|
57
|
+
}
|
|
58
|
+
</style>
|
|
59
|
+
</head>
|
|
60
|
+
<body>
|
|
61
|
+
<nav><a id="rail" href="#cards">Cards</a></nav>
|
|
62
|
+
<canvas id="hero"></canvas>
|
|
63
|
+
<section>one</section>
|
|
64
|
+
<section>two</section>
|
|
65
|
+
<section id="cards"><div class="card" id="card">card</div></section>
|
|
66
|
+
<section>four</section>
|
|
67
|
+
<script>
|
|
68
|
+
const params = new URLSearchParams(location.search)
|
|
69
|
+
const busy = Number(params.get('busy') || 0)
|
|
70
|
+
const timed = params.get('timed') === '1'
|
|
71
|
+
const hero = document.getElementById('hero')
|
|
72
|
+
hero.width = innerWidth
|
|
73
|
+
hero.height = innerHeight
|
|
74
|
+
const context = hero.getContext('2d')
|
|
75
|
+
// The work is a count calibrated in real time on load, since a loop
|
|
76
|
+
// timed by performance.now() never ends under a paused virtual clock.
|
|
77
|
+
let sink = 0
|
|
78
|
+
const spin = (count) => {
|
|
79
|
+
for (let i = 0; i < count; i += 1) sink += Math.sqrt(i)
|
|
80
|
+
}
|
|
81
|
+
const measure = (ms) => {
|
|
82
|
+
let count = 0
|
|
83
|
+
const start = performance.now()
|
|
84
|
+
while (performance.now() - start < ms) {
|
|
85
|
+
spin(10000)
|
|
86
|
+
count += 10000
|
|
87
|
+
}
|
|
88
|
+
return count / ms
|
|
89
|
+
}
|
|
90
|
+
// Warmed first, so the count matches the optimized loop a frame runs.
|
|
91
|
+
measure(100)
|
|
92
|
+
const work = Math.round(busy * measure(200))
|
|
93
|
+
const draw = (time) => {
|
|
94
|
+
if (timed) {
|
|
95
|
+
const start = performance.now()
|
|
96
|
+
while (performance.now() - start < busy) {}
|
|
97
|
+
} else {
|
|
98
|
+
spin(work)
|
|
99
|
+
}
|
|
100
|
+
context.fillStyle = `hsl(${(time / 10) % 360}, 60%, 50%)`
|
|
101
|
+
context.fillRect(0, 0, hero.width, hero.height)
|
|
102
|
+
requestAnimationFrame(draw)
|
|
103
|
+
}
|
|
104
|
+
requestAnimationFrame(draw)
|
|
105
|
+
</script>
|
|
106
|
+
</body>
|
|
107
|
+
</html>
|
package/src/take/perform.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
FPS,
|
|
4
4
|
type LogEvent,
|
|
5
5
|
type Point,
|
|
6
|
+
VIEWPORT,
|
|
6
7
|
along,
|
|
7
8
|
travelMs,
|
|
8
9
|
} from '@/take/log'
|
|
@@ -74,13 +75,40 @@ export interface Stage {
|
|
|
74
75
|
evaluate<T>(expression: string): Promise<T>
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
/** A target
|
|
78
|
-
export class
|
|
78
|
+
/** A target the pointer cannot reach, which refuses rather than films a guess. */
|
|
79
|
+
export class TargetRefused extends Error {
|
|
79
80
|
/** The page as it stood when the lookup failed, as a JPEG, when the route caught one. */
|
|
80
81
|
still?: Buffer
|
|
81
82
|
|
|
82
|
-
constructor(
|
|
83
|
-
|
|
83
|
+
constructor(
|
|
84
|
+
readonly target: string,
|
|
85
|
+
message: string,
|
|
86
|
+
) {
|
|
87
|
+
super(message)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A target that resolved to nothing. */
|
|
92
|
+
export class TargetMissing extends TargetRefused {
|
|
93
|
+
constructor(target: string) {
|
|
94
|
+
super(target, `nothing on the page matches ${target}`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A target whose center lies outside the window, so the pointer would aim at
|
|
100
|
+
* a point the clip never shows, or for a scroll, a target with no part inside
|
|
101
|
+
* it. A setup step brings it into view first.
|
|
102
|
+
*/
|
|
103
|
+
export class TargetOffscreen extends TargetRefused {
|
|
104
|
+
constructor(
|
|
105
|
+
target: string,
|
|
106
|
+
readonly at: Point,
|
|
107
|
+
) {
|
|
108
|
+
super(
|
|
109
|
+
target,
|
|
110
|
+
`${target} is centered at (${Math.round(at.x)}, ${Math.round(at.y)}), outside the ${VIEWPORT.width}x${VIEWPORT.height} window, so a setup step must bring it into view first`,
|
|
111
|
+
)
|
|
84
112
|
}
|
|
85
113
|
}
|
|
86
114
|
|
|
@@ -155,19 +183,12 @@ export async function performSteps(
|
|
|
155
183
|
break
|
|
156
184
|
}
|
|
157
185
|
case 'scroll': {
|
|
158
|
-
const at =
|
|
186
|
+
const at = await scrollPoint(stage, step.target)
|
|
159
187
|
await moveTo(stage, state, at)
|
|
160
188
|
const frames = Math.round(SCROLL_MS / FRAME_MS)
|
|
161
|
-
|
|
162
|
-
t: stage.now(),
|
|
163
|
-
kind: 'scroll',
|
|
164
|
-
at,
|
|
165
|
-
dx: 0,
|
|
166
|
-
dy: step.dy,
|
|
167
|
-
ms: frames * FRAME_MS,
|
|
168
|
-
})
|
|
189
|
+
const start = stage.now()
|
|
169
190
|
let sent = 0
|
|
170
|
-
|
|
191
|
+
await paced(stage, frames, async (frame) => {
|
|
171
192
|
const want = along(
|
|
172
193
|
{ x: 0, y: 0 },
|
|
173
194
|
{ x: 0, y: step.dy },
|
|
@@ -175,7 +196,15 @@ export async function performSteps(
|
|
|
175
196
|
).y
|
|
176
197
|
await stage.pointer({ type: 'wheel', ...at, dy: want - sent })
|
|
177
198
|
sent = want
|
|
178
|
-
}
|
|
199
|
+
})
|
|
200
|
+
state.log?.push({
|
|
201
|
+
t: start,
|
|
202
|
+
kind: 'scroll',
|
|
203
|
+
at,
|
|
204
|
+
dx: 0,
|
|
205
|
+
dy: step.dy,
|
|
206
|
+
ms: stage.now() - start,
|
|
207
|
+
})
|
|
179
208
|
break
|
|
180
209
|
}
|
|
181
210
|
case 'wait':
|
|
@@ -194,21 +223,35 @@ async function moveTo(
|
|
|
194
223
|
const from = state.pointer
|
|
195
224
|
if (Math.hypot(to.x - from.x, to.y - from.y) < 1) return
|
|
196
225
|
const frames = Math.max(1, Math.round(travelMs(from, to) / FRAME_MS))
|
|
197
|
-
state.log?.push({
|
|
198
|
-
t: stage.now(),
|
|
199
|
-
kind: 'move',
|
|
200
|
-
from,
|
|
201
|
-
to,
|
|
202
|
-
ms: frames * FRAME_MS,
|
|
203
|
-
})
|
|
204
226
|
const pressed = held
|
|
205
227
|
? { held: true as const, ...withModifiers(held.modifiers) }
|
|
206
228
|
: {}
|
|
207
|
-
|
|
229
|
+
const start = stage.now()
|
|
230
|
+
await paced(stage, frames, async (frame) => {
|
|
208
231
|
state.pointer = along(from, to, frame / frames)
|
|
209
232
|
await stage.pointer({ type: 'move', ...state.pointer, ...pressed })
|
|
210
|
-
}
|
|
233
|
+
})
|
|
211
234
|
state.pointer = to
|
|
235
|
+
state.log?.push({ t: start, kind: 'move', from, to, ms: stage.now() - start })
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Runs a motion planned as `frames` inputs, sending each at the frame the
|
|
240
|
+
* stage's clock has reached, so a page slower than a frame per input skips the
|
|
241
|
+
* points it is behind on and the motion still ends near its planned length. A
|
|
242
|
+
* clock advancing one frame per input, as the stepper's does, gets every frame.
|
|
243
|
+
*/
|
|
244
|
+
async function paced(
|
|
245
|
+
stage: Stage,
|
|
246
|
+
frames: number,
|
|
247
|
+
input: (frame: number) => Promise<void>,
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
const start = stage.now()
|
|
250
|
+
for (let frame = 0; frame < frames;) {
|
|
251
|
+
const reached = Math.round((stage.now() - start) / FRAME_MS)
|
|
252
|
+
frame = Math.min(frames, Math.max(frame + 1, reached + 1))
|
|
253
|
+
await input(frame)
|
|
254
|
+
}
|
|
212
255
|
}
|
|
213
256
|
|
|
214
257
|
async function click(
|
|
@@ -315,12 +358,43 @@ function center(box: Box): Point {
|
|
|
315
358
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
|
316
359
|
}
|
|
317
360
|
|
|
318
|
-
|
|
361
|
+
function inWindow(at: Point): boolean {
|
|
362
|
+
return (
|
|
363
|
+
at.x >= 0 && at.x <= VIEWPORT.width && at.y >= 0 && at.y <= VIEWPORT.height
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function foundBox(stage: Stage, target: string): Promise<Box> {
|
|
319
368
|
const box = await stage.evaluate<Box | null>(boxExpression(target))
|
|
320
369
|
if (!box) throw new TargetMissing(target)
|
|
321
370
|
return box
|
|
322
371
|
}
|
|
323
372
|
|
|
373
|
+
async function boxOf(stage: Stage, target: string): Promise<Box> {
|
|
374
|
+
const box = await foundBox(stage, target)
|
|
375
|
+
const at = center(box)
|
|
376
|
+
if (!inWindow(at)) throw new TargetOffscreen(target, at)
|
|
377
|
+
return box
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Aims a wheel at the target's center, or, when that lies outside the window,
|
|
382
|
+
* at the center of the part the window shows. A wheel scrolls whatever sits
|
|
383
|
+
* under it, so any visible point of the target serves, and a page-tall element
|
|
384
|
+
* such as `body` has a center no setup step can bring into view.
|
|
385
|
+
*/
|
|
386
|
+
async function scrollPoint(stage: Stage, target: string): Promise<Point> {
|
|
387
|
+
const box = await foundBox(stage, target)
|
|
388
|
+
const at = center(box)
|
|
389
|
+
if (inWindow(at)) return at
|
|
390
|
+
const left = Math.max(box.x, 0)
|
|
391
|
+
const right = Math.min(box.x + box.width, VIEWPORT.width)
|
|
392
|
+
const top = Math.max(box.y, 0)
|
|
393
|
+
const bottom = Math.min(box.y + box.height, VIEWPORT.height)
|
|
394
|
+
if (right <= left || bottom <= top) throw new TargetOffscreen(target, at)
|
|
395
|
+
return { x: (left + right) / 2, y: (top + bottom) / 2 }
|
|
396
|
+
}
|
|
397
|
+
|
|
324
398
|
/**
|
|
325
399
|
* Finds a target's box in CSS pixels, reading `text=<words>` as the first
|
|
326
400
|
* innermost element with a size whose whole text is those words, so a hidden
|
package/src/take/screencast.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
|
|
|
7
7
|
import {
|
|
8
8
|
type PointerInput,
|
|
9
9
|
type Stage,
|
|
10
|
-
|
|
10
|
+
TargetRefused,
|
|
11
11
|
performSteps,
|
|
12
12
|
} from '@/take/perform'
|
|
13
13
|
import type { Beat } from '@/take/plan'
|
|
@@ -92,13 +92,14 @@ export async function filmScreencast(request: {
|
|
|
92
92
|
log: events,
|
|
93
93
|
})
|
|
94
94
|
await recording.hold(HOLD_MS)
|
|
95
|
+
const filmedMs = recording.now()
|
|
95
96
|
await page.screencast.stop()
|
|
96
97
|
|
|
97
|
-
await transcode(webm, request.clip, FPS)
|
|
98
|
+
await transcode(webm, request.clip, FPS, filmedMs)
|
|
98
99
|
const frames = await probeFrames(request.clip)
|
|
99
100
|
return { frames, events, still: await lastFrame(request.clip, frames) }
|
|
100
101
|
} catch (error) {
|
|
101
|
-
if (error instanceof
|
|
102
|
+
if (error instanceof TargetRefused && still) error.still = await still()
|
|
102
103
|
throw error
|
|
103
104
|
} finally {
|
|
104
105
|
await browser.close()
|
package/src/take/stepper.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type KeyInput,
|
|
6
6
|
type PointerInput,
|
|
7
7
|
type Stage,
|
|
8
|
-
|
|
8
|
+
TargetRefused,
|
|
9
9
|
performSteps,
|
|
10
10
|
} from '@/take/perform'
|
|
11
11
|
import type { Beat } from '@/take/plan'
|
|
@@ -25,7 +25,10 @@ import { type ShellPage, openShell } from '@/take/shell'
|
|
|
25
25
|
* - Frame time continues Chromium's monotonic clock, since a frame stamped on
|
|
26
26
|
* any other base leaves every CSS animation at its first frame
|
|
27
27
|
* - No frame is begun before the load event, since one sent mid-navigation can
|
|
28
|
-
* go unanswered
|
|
28
|
+
* go unanswered
|
|
29
|
+
* - A frame is begun only once the one before it settles, since Chromium
|
|
30
|
+
* refuses a second while one is pending, and a frame left pending past the
|
|
31
|
+
* stall limit falls the beat back rather than hanging it
|
|
29
32
|
*/
|
|
30
33
|
|
|
31
34
|
const FRAME_MS = 1000 / FPS
|
|
@@ -41,7 +44,6 @@ export const HOLD_MS = 600
|
|
|
41
44
|
|
|
42
45
|
const LOAD_CAP_MS = 15_000
|
|
43
46
|
const SETTLE_MS = 2500
|
|
44
|
-
const LOADING_FRAME_CAP_MS = 250
|
|
45
47
|
|
|
46
48
|
/** How long a refusal waits on the frame it shows, since the page may be wedged. */
|
|
47
49
|
const STILL_CAP_MS = 2000
|
|
@@ -81,19 +83,25 @@ export async function filmStepped(
|
|
|
81
83
|
let still: (() => Promise<Buffer | undefined>) | undefined
|
|
82
84
|
try {
|
|
83
85
|
let ticks = await load(page, request.url)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
{
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
86
|
+
let outstanding: Promise<unknown> = Promise.resolve()
|
|
87
|
+
const beginFrame = (screenshot: boolean) => {
|
|
88
|
+
const reply = outstanding.then(() =>
|
|
89
|
+
page.send<{ screenshotData?: string }>(
|
|
90
|
+
'HeadlessExperimental.beginFrame',
|
|
91
|
+
{
|
|
92
|
+
frameTimeTicks: (ticks += FRAME_MS),
|
|
93
|
+
interval: FRAME_MS,
|
|
94
|
+
...(screenshot
|
|
95
|
+
? { screenshot: { format: 'jpeg', quality: 95 } }
|
|
96
|
+
: {}),
|
|
97
|
+
},
|
|
98
|
+
),
|
|
94
99
|
)
|
|
100
|
+
outstanding = reply.catch(() => {})
|
|
101
|
+
return reply
|
|
102
|
+
}
|
|
95
103
|
still = () => frameStill(beginFrame)
|
|
96
|
-
await pump(beginFrame, SETTLE_MS)
|
|
104
|
+
await pump(beginFrame, SETTLE_MS, stallMs)
|
|
97
105
|
|
|
98
106
|
const live = liveStage(page, beginFrame, stallMs)
|
|
99
107
|
await performSteps(live, request.beat.setup, { pointer: START })
|
|
@@ -163,7 +171,7 @@ export async function filmStepped(
|
|
|
163
171
|
return { frames, events, still: last }
|
|
164
172
|
} catch (error) {
|
|
165
173
|
encoder.abort()
|
|
166
|
-
if (error instanceof
|
|
174
|
+
if (error instanceof TargetRefused && still) error.still = await still()
|
|
167
175
|
throw error
|
|
168
176
|
} finally {
|
|
169
177
|
page.close()
|
|
@@ -215,17 +223,22 @@ async function load(page: ShellPage, url: string): Promise<number> {
|
|
|
215
223
|
return timestamp
|
|
216
224
|
}
|
|
217
225
|
|
|
218
|
-
/**
|
|
226
|
+
/**
|
|
227
|
+
* Begins frames in real time so loading, fonts, and layout settle. Each frame
|
|
228
|
+
* is awaited, so a slow page stretches the settle rather than overlapping it.
|
|
229
|
+
*/
|
|
219
230
|
async function pump(
|
|
220
231
|
beginFrame: (screenshot: boolean) => Promise<unknown>,
|
|
221
232
|
ms: number,
|
|
233
|
+
stallMs: number,
|
|
222
234
|
): Promise<void> {
|
|
223
235
|
const end = Date.now() + ms
|
|
224
236
|
while (Date.now() < end) {
|
|
225
|
-
await
|
|
237
|
+
await withinStall(
|
|
226
238
|
beginFrame(false).catch(() => {}),
|
|
227
|
-
|
|
228
|
-
|
|
239
|
+
0,
|
|
240
|
+
stallMs,
|
|
241
|
+
)
|
|
229
242
|
await sleep(12)
|
|
230
243
|
}
|
|
231
244
|
}
|
|
@@ -242,7 +255,7 @@ function liveStage(
|
|
|
242
255
|
const started = Date.now()
|
|
243
256
|
return {
|
|
244
257
|
now: () => Date.now() - started,
|
|
245
|
-
hold: (ms) => pump(beginFrame, ms),
|
|
258
|
+
hold: (ms) => pump(beginFrame, ms, stallMs),
|
|
246
259
|
pointer: (input) => {
|
|
247
260
|
const sent = page.send('Input.dispatchMouseEvent', mouseEvent(input))
|
|
248
261
|
return withinStall(
|
package/src/targets/pulls.ts
CHANGED
|
@@ -94,6 +94,8 @@ interface RawPull {
|
|
|
94
94
|
* rather than writing a second collapse beside `rollup`.
|
|
95
95
|
*/
|
|
96
96
|
export interface RawCheck {
|
|
97
|
+
/** The check-run name, which a ruleset's required context matches. */
|
|
98
|
+
readonly name?: string
|
|
97
99
|
readonly status?: string
|
|
98
100
|
readonly conclusion?: string
|
|
99
101
|
readonly state?: string
|
|
@@ -137,6 +139,46 @@ export function rollup(checks: readonly RawCheck[]): ChecksState | null {
|
|
|
137
139
|
return running ? 'pending' : 'passing'
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
/** A head's state over the checks the base requires, with failing others named apart. */
|
|
143
|
+
export interface RequiredRollup {
|
|
144
|
+
readonly state: ChecksState | null
|
|
145
|
+
/** Names of checks outside the required set that failed, which never gate. */
|
|
146
|
+
readonly advisory: readonly string[]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Collapses a head's checks over the set the base branch's rules require.
|
|
151
|
+
*
|
|
152
|
+
* An unread or empty set gates by every check, which is `rollup` itself and
|
|
153
|
+
* the safe default for a repository whose rules list nothing. A required name
|
|
154
|
+
* with no run on the head reads pending rather than passing, since a run not
|
|
155
|
+
* yet created has not cleared anything.
|
|
156
|
+
*/
|
|
157
|
+
export function rollupRequired(
|
|
158
|
+
checks: readonly RawCheck[],
|
|
159
|
+
required: ReadonlySet<string> | undefined,
|
|
160
|
+
): RequiredRollup {
|
|
161
|
+
if (required === undefined || required.size === 0) {
|
|
162
|
+
return { state: rollup(checks), advisory: [] }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const isRequired = (check: RawCheck): boolean =>
|
|
166
|
+
check.name !== undefined && required.has(check.name)
|
|
167
|
+
const gating = checks.filter(isRequired)
|
|
168
|
+
const advisory = checks
|
|
169
|
+
.filter((check) => !isRequired(check) && rollup([check]) === 'failing')
|
|
170
|
+
.map((check) => check.name ?? '')
|
|
171
|
+
|
|
172
|
+
const state = rollup(gating)
|
|
173
|
+
const isMissing = [...required].some(
|
|
174
|
+
(name) => !gating.some((check) => check.name === name),
|
|
175
|
+
)
|
|
176
|
+
return {
|
|
177
|
+
state: state !== 'failing' && isMissing ? 'pending' : state,
|
|
178
|
+
advisory,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
140
182
|
/**
|
|
141
183
|
* Reads the heading of the newest pass carrying one, matching on the first line
|
|
142
184
|
* alone the way `poll.ts` does.
|