@iceinvein/agent-skills 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/bug-hunter/SKILL.md +5 -0
- package/skills/bug-hunter/evals/README.md +6 -1
- package/skills/bug-hunter/evals/callers-tree-unchanged/graders/output-ignores-itself.md +11 -0
- package/skills/bug-hunter/evals/rules.json +8 -0
- package/skills/bug-hunter/skill.json +1 -1
- package/skills/index.json +4 -4
- package/skills/magpie/README.md +5 -3
- package/skills/magpie/SKILL.md +40 -15
- package/skills/magpie/bin/magpie.ts +83 -7
- package/skills/magpie/evals/README.md +7 -4
- package/skills/magpie/evals/local-mode-hands-back/case.yaml +4 -0
- package/skills/magpie/evals/local-mode-hands-back/fixture.sh +300 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/branch-was-reachable.md +5 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/cleanup-after-handoff.md +6 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/handoff-was-run.md +6 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/nothing-served-or-posted.md +7 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/reply-lists-both-findings.md +10 -0
- package/skills/magpie/evals/local-mode-hands-back/graders/skill-fired.md +5 -0
- package/skills/magpie/evals/local-mode-hands-back/prompt.md +11 -0
- package/skills/magpie/evals/quality/README.md +73 -2
- package/skills/magpie/evals/quality/__tests__/recall.test.ts +234 -0
- package/skills/magpie/evals/quality/__tests__/replay-full.test.ts +164 -0
- package/skills/magpie/evals/quality/corpus.ts +4 -1
- package/skills/magpie/evals/quality/recall.ts +249 -0
- package/skills/magpie/evals/quality/replay-critic.ts +3 -0
- package/skills/magpie/evals/quality/replay-full.ts +168 -6
- package/skills/magpie/evals/rules.json +9 -1
- package/skills/magpie/references/checks.md +78 -0
- package/skills/magpie/references/peer-review.md +7 -0
- package/skills/magpie/references/scout.md +12 -5
- package/skills/magpie/references/specialists.md +10 -8
- package/skills/magpie/scripts/__tests__/cleanup-cmd.test.ts +94 -0
- package/skills/magpie/scripts/__tests__/critic-cmd.test.ts +18 -0
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +80 -1
- package/skills/magpie/scripts/__tests__/handoff-cmd.test.ts +319 -0
- package/skills/magpie/scripts/__tests__/housekeeping-cmd.test.ts +14 -0
- package/skills/magpie/scripts/__tests__/local-diff.test.ts +275 -0
- package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +1 -0
- package/skills/magpie/scripts/__tests__/resolve-run.test.ts +28 -0
- package/skills/magpie/scripts/__tests__/server.test.ts +30 -1
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +324 -1
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +8 -5
- package/skills/magpie/scripts/__tests__/status-cmd.test.ts +44 -0
- package/skills/magpie/scripts/__tests__/target.test.ts +84 -0
- package/skills/magpie/scripts/__tests__/types.test.ts +2 -1
- package/skills/magpie/scripts/cleanup-cmd.ts +5 -1
- package/skills/magpie/scripts/critic-cmd.ts +2 -1
- package/skills/magpie/scripts/dedupe-cmd.ts +15 -5
- package/skills/magpie/scripts/handoff-cmd.ts +132 -0
- package/skills/magpie/scripts/housekeeping-cmd.ts +7 -5
- package/skills/magpie/scripts/local-diff.ts +228 -0
- package/skills/magpie/scripts/post-cmd.ts +1 -0
- package/skills/magpie/scripts/render-annotation.ts +1 -0
- package/skills/magpie/scripts/resolve-run.ts +17 -3
- package/skills/magpie/scripts/server.ts +9 -1
- package/skills/magpie/scripts/setup-cmd.ts +241 -49
- package/skills/magpie/scripts/status-cmd.ts +22 -6
- package/skills/magpie/scripts/target.ts +56 -0
- package/skills/magpie/scripts/types.ts +1 -0
- package/skills/magpie/skill.json +2 -2
- package/skills/sluice/SKILL.md +3 -1
- package/skills/sluice/evals/README.md +10 -2
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/case.yaml +4 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/fixture.sh +108 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/deploy-names-its-steps.md +9 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/kept-status-by-changing-code.md +12 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/sluice-fired.md +5 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/graders/status-test-unchanged.md +6 -0
- package/skills/sluice/evals/fast-leaves-an-unrelated-red-test-alone/prompt.md +11 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/case.yaml +4 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/fixture.sh +73 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/proved-after-the-last-edit.md +18 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/sluice-fired.md +5 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/test-expects-the-new-line.md +5 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/graders/updated-without-asking.md +11 -0
- package/skills/sluice/evals/fast-updates-the-test-the-change-replaces/prompt.md +11 -0
- package/skills/sluice/evals/rules.json +11 -0
- package/skills/sluice/references/review.md +6 -4
- package/skills/sluice/references/test-first.md +24 -4
- package/skills/sluice/scripts/content-digest.sh +1 -1
- package/skills/sluice/scripts/run-stats.sh +14 -0
- package/skills/sluice/scripts/session-start.sh +44 -2
- package/skills/sluice/scripts/status.sh +293 -0
- package/skills/sluice/scripts/stop-guard.sh +80 -6
- package/skills/sluice/scripts/test-changes.sh +135 -0
- package/skills/sluice/skill.json +1 -1
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, mock, spyOn, test } from 'bun:test'
|
|
2
|
+
import { mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { formatHandoff, runHandoff } from '../handoff-cmd.ts'
|
|
6
|
+
import { diffSha256, localBranchDiff } from '../local-diff.ts'
|
|
7
|
+
import type { ReviewFinding } from '../types.ts'
|
|
8
|
+
|
|
9
|
+
const WARNING = 'Warning: the tree changed since this review began; line anchors may be off.'
|
|
10
|
+
const FOOTER = 'Address every must-fix and should-fix, or say why not. The rest are optional.'
|
|
11
|
+
|
|
12
|
+
const shouldFix: ReviewFinding = {
|
|
13
|
+
id: 'f-should',
|
|
14
|
+
file: 'src/b.ts',
|
|
15
|
+
line: 20,
|
|
16
|
+
severity: 'medium',
|
|
17
|
+
risk: { impact: 'medium', likelihood: 'possible', confidence: 'medium', action: 'should-fix' },
|
|
18
|
+
title: 'Retry count is off by one',
|
|
19
|
+
description: 'Observation: the loop retries four times instead of three.',
|
|
20
|
+
domain: null,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const mustFix: ReviewFinding = {
|
|
24
|
+
id: 'f-must',
|
|
25
|
+
file: 'src/a.ts',
|
|
26
|
+
line: 10,
|
|
27
|
+
severity: 'blocker',
|
|
28
|
+
risk: { impact: 'critical', likelihood: 'likely', confidence: 'high', action: 'must-fix' },
|
|
29
|
+
title: 'Token leaks into the log',
|
|
30
|
+
description: 'Observation: the bearer token is written to stdout.',
|
|
31
|
+
suggestion: { body: 'log.info(redact(token))', startLine: 10, endLine: 10 },
|
|
32
|
+
domain: null,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const consider: ReviewFinding = {
|
|
36
|
+
id: 'f-consider',
|
|
37
|
+
file: 'README.md',
|
|
38
|
+
line: null,
|
|
39
|
+
severity: 'low',
|
|
40
|
+
risk: { impact: 'low', likelihood: 'edge-case', confidence: 'low', action: 'consider' },
|
|
41
|
+
title: 'Mention the new flag',
|
|
42
|
+
description: 'Observation: the flag is undocumented.',
|
|
43
|
+
domain: null,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const optional: ReviewFinding = {
|
|
47
|
+
id: 'f-optional',
|
|
48
|
+
file: 'src/c.ts',
|
|
49
|
+
line: 3,
|
|
50
|
+
severity: 'low',
|
|
51
|
+
risk: { impact: 'low', likelihood: 'edge-case', confidence: 'low', action: 'optional' },
|
|
52
|
+
title: 'Name the constant',
|
|
53
|
+
description: 'Observation: the magic number has no name.',
|
|
54
|
+
domain: null,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const findings = [shouldFix, consider, mustFix, optional]
|
|
58
|
+
|
|
59
|
+
function render(stale: boolean): string {
|
|
60
|
+
return formatHandoff({
|
|
61
|
+
target: 'local branch feat/x against main',
|
|
62
|
+
findings,
|
|
63
|
+
stale,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test('formatHandoff opens with the target header', () => {
|
|
68
|
+
expect(render(false).split('\n')[0]).toBe(
|
|
69
|
+
'# magpie local review: local branch feat/x against main',
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('formatHandoff counts consider and optional together as optional', () => {
|
|
74
|
+
expect(render(false)).toContain('1 must-fix, 1 should-fix, 2 optional')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('formatHandoff goes straight from the counts to the first finding', () => {
|
|
78
|
+
expect(render(false)).toContain(
|
|
79
|
+
'1 must-fix, 1 should-fix, 2 optional\n\n1. [must-fix] src/a.ts:10: Token leaks into the log',
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('formatHandoff numbers actionable findings by risk, then suggestions', () => {
|
|
84
|
+
const out = render(false)
|
|
85
|
+
const first = out.indexOf('1. [must-fix] src/a.ts:10: Token leaks into the log')
|
|
86
|
+
const second = out.indexOf('2. [should-fix] src/b.ts:20: Retry count is off by one')
|
|
87
|
+
const third = out.indexOf('3. [consider] README.md: Mention the new flag')
|
|
88
|
+
const fourth = out.indexOf('4. [optional] src/c.ts:3: Name the constant')
|
|
89
|
+
expect(first).toBeGreaterThan(-1)
|
|
90
|
+
expect(second).toBeGreaterThan(first)
|
|
91
|
+
expect(third).toBeGreaterThan(second)
|
|
92
|
+
expect(fourth).toBeGreaterThan(third)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('formatHandoff renders each description as markdown sections', () => {
|
|
96
|
+
expect(render(false)).toContain('**Observation:** the bearer token is written to stdout.')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('formatHandoff fences the suggestion body under a single-line label', () => {
|
|
100
|
+
expect(render(false)).toContain(
|
|
101
|
+
'Suggested replacement for line 10:\n```\nlog.info(redact(token))\n```',
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
function renderOne(suggestion: ReviewFinding['suggestion']): string {
|
|
106
|
+
return formatHandoff({
|
|
107
|
+
target: 't',
|
|
108
|
+
findings: [{ ...mustFix, suggestion }],
|
|
109
|
+
stale: false,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
test('formatHandoff labels a multi-line suggestion with its line range', () => {
|
|
114
|
+
expect(renderOne({ body: 'a()\nb()', startLine: 4, endLine: 5 })).toContain(
|
|
115
|
+
'Suggested replacement for lines 4-5:\n```\na()\nb()\n```',
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('formatHandoff fences a body holding backticks one backtick longer', () => {
|
|
120
|
+
const body = 'doc = `\n```\n`'
|
|
121
|
+
expect(renderOne({ body, startLine: 1, endLine: 3 })).toContain(`\`\`\`\`\n${body}\n\`\`\`\``)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('formatHandoff ends with the footer', () => {
|
|
125
|
+
expect(render(false).trimEnd().endsWith(FOOTER)).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('formatHandoff puts the stale warning directly after the header', () => {
|
|
129
|
+
const lines = render(true).split('\n')
|
|
130
|
+
expect(lines[1]).toBe(WARNING)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('formatHandoff omits the stale warning when fresh', () => {
|
|
134
|
+
expect(render(false)).not.toContain('Warning:')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('formatHandoff with no findings prints only the header and No findings.', () => {
|
|
138
|
+
const out = formatHandoff({ target: 't', findings: [], stale: false })
|
|
139
|
+
expect(out).toBe('# magpie local review: t\n\nNo findings.\n')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
let repo: string
|
|
143
|
+
let runDir: string
|
|
144
|
+
let stdout: string
|
|
145
|
+
let stderr: string
|
|
146
|
+
|
|
147
|
+
async function sh(cwd: string, ...cmd: string[]): Promise<void> {
|
|
148
|
+
const proc = Bun.spawn(cmd, { cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
149
|
+
const exit = await proc.exited
|
|
150
|
+
if (exit !== 0) {
|
|
151
|
+
const err = await new Response(proc.stderr).text()
|
|
152
|
+
throw new Error(`${cmd.join(' ')} exit ${exit}: ${err}`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
beforeEach(async () => {
|
|
157
|
+
repo = await mkdtemp(join(tmpdir(), 'magpie-handoff-repo-'))
|
|
158
|
+
runDir = await mkdtemp(join(tmpdir(), 'magpie-handoff-run-'))
|
|
159
|
+
await sh(repo, 'git', 'init', '-q', '-b', 'main')
|
|
160
|
+
await sh(repo, 'git', 'config', 'user.email', 't@t.t')
|
|
161
|
+
await sh(repo, 'git', 'config', 'user.name', 't')
|
|
162
|
+
await sh(repo, 'git', 'config', 'commit.gpgsign', 'false')
|
|
163
|
+
await writeFile(join(repo, 'a.txt'), 'one\n')
|
|
164
|
+
await sh(repo, 'git', 'add', '.')
|
|
165
|
+
await sh(repo, 'git', 'commit', '-q', '-m', 'init')
|
|
166
|
+
await sh(repo, 'git', 'checkout', '-q', '-b', 'feat/x')
|
|
167
|
+
await writeFile(join(repo, 'a.txt'), 'one\ntwo\n')
|
|
168
|
+
await sh(repo, 'git', 'commit', '-q', '-am', 'add two')
|
|
169
|
+
stdout = ''
|
|
170
|
+
stderr = ''
|
|
171
|
+
spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => {
|
|
172
|
+
stdout += String(chunk)
|
|
173
|
+
return true
|
|
174
|
+
})
|
|
175
|
+
spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => {
|
|
176
|
+
stderr += String(chunk)
|
|
177
|
+
return true
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
afterEach(async () => {
|
|
182
|
+
mock.restore()
|
|
183
|
+
await rm(repo, { recursive: true, force: true })
|
|
184
|
+
await rm(runDir, { recursive: true, force: true })
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
async function writeLocalRun(): Promise<void> {
|
|
188
|
+
const r = await localBranchDiff({ gitBin: 'git', repoPath: repo, baseRef: 'main' })
|
|
189
|
+
if (!r.ok) throw new Error(r.error)
|
|
190
|
+
await writeFile(join(runDir, 'target.json'), JSON.stringify({ mode: 'local', root: repo }))
|
|
191
|
+
await writeFile(
|
|
192
|
+
join(runDir, 'local.json'),
|
|
193
|
+
JSON.stringify({
|
|
194
|
+
branch: 'feat/x',
|
|
195
|
+
baseRef: 'main',
|
|
196
|
+
mergeBase: r.mergeBase,
|
|
197
|
+
head: r.head,
|
|
198
|
+
dirty: false,
|
|
199
|
+
author: 't',
|
|
200
|
+
commits: [],
|
|
201
|
+
intentFile: null,
|
|
202
|
+
}),
|
|
203
|
+
)
|
|
204
|
+
await writeFile(join(runDir, 'findings.final.json'), JSON.stringify([mustFix]))
|
|
205
|
+
await writeFile(
|
|
206
|
+
join(runDir, 'diff-source.json'),
|
|
207
|
+
JSON.stringify({
|
|
208
|
+
source: 'local',
|
|
209
|
+
mergeBase: r.mergeBase,
|
|
210
|
+
baseRef: r.baseRef,
|
|
211
|
+
head: r.head,
|
|
212
|
+
diffSha256: diffSha256(r.diff),
|
|
213
|
+
}),
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
test('runHandoff prints a fresh handoff for an unchanged tree', async () => {
|
|
218
|
+
await writeLocalRun()
|
|
219
|
+
const exit = await runHandoff(runDir)
|
|
220
|
+
expect(exit).toBe(0)
|
|
221
|
+
expect(stdout).toContain('# magpie local review: local branch feat/x against main')
|
|
222
|
+
expect(stdout).toContain('1. [must-fix] src/a.ts:10: Token leaks into the log')
|
|
223
|
+
expect(stdout).not.toContain(WARNING)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('runHandoff logs a done handoff entry with the finding count', async () => {
|
|
227
|
+
await writeLocalRun()
|
|
228
|
+
await runHandoff(runDir)
|
|
229
|
+
const lines = (await readFile(join(runDir, 'log.jsonl'), 'utf8')).trim().split('\n')
|
|
230
|
+
const entry = JSON.parse(lines[lines.length - 1] as string)
|
|
231
|
+
expect(entry.stage).toBe('handoff')
|
|
232
|
+
expect(entry.status).toBe('done')
|
|
233
|
+
expect(entry.findings).toBe(1)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test('runHandoff warns when the tree changed after the review began', async () => {
|
|
237
|
+
await writeLocalRun()
|
|
238
|
+
await writeFile(join(repo, 'a.txt'), 'one\ntwo\nthree\n')
|
|
239
|
+
const exit = await runHandoff(runDir)
|
|
240
|
+
expect(exit).toBe(0)
|
|
241
|
+
expect(stdout).toContain(WARNING)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('runHandoff warns after a same-size edit made in the same second as the last index write', async () => {
|
|
245
|
+
// b.txt matches the merge base, so git trusts the index alone to call it unchanged.
|
|
246
|
+
await sh(repo, 'git', 'checkout', '-q', 'main')
|
|
247
|
+
await writeFile(join(repo, 'b.txt'), 'bee\n')
|
|
248
|
+
await sh(repo, 'git', 'add', 'b.txt')
|
|
249
|
+
await sh(repo, 'git', 'commit', '-q', '-m', 'add b')
|
|
250
|
+
await sh(repo, 'git', 'checkout', '-q', 'feat/x')
|
|
251
|
+
await sh(repo, 'git', 'rebase', '-q', 'main')
|
|
252
|
+
await writeLocalRun()
|
|
253
|
+
// Pinning b.txt and the index to one whole second reproduces the racy case
|
|
254
|
+
// without waiting on the clock; trustctime=false because ctime cannot be set.
|
|
255
|
+
await sh(repo, 'git', 'config', 'core.trustctime', 'false')
|
|
256
|
+
const second = new Date('2020-01-01T00:00:00Z')
|
|
257
|
+
await utimes(join(repo, 'b.txt'), second, second)
|
|
258
|
+
await sh(repo, 'git', 'update-index', '--refresh')
|
|
259
|
+
await writeFile(join(repo, 'b.txt'), 'bed\n')
|
|
260
|
+
await utimes(join(repo, 'b.txt'), second, second)
|
|
261
|
+
await utimes(join(repo, '.git', 'index'), second, second)
|
|
262
|
+
const exit = await runHandoff(runDir)
|
|
263
|
+
expect(exit).toBe(0)
|
|
264
|
+
expect(stdout).toContain(WARNING)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('runHandoff names a detached HEAD in the target', async () => {
|
|
268
|
+
await writeLocalRun()
|
|
269
|
+
const meta = JSON.parse(await readFile(join(runDir, 'local.json'), 'utf8'))
|
|
270
|
+
await writeFile(join(runDir, 'local.json'), JSON.stringify({ ...meta, branch: null }))
|
|
271
|
+
await runHandoff(runDir)
|
|
272
|
+
expect(stdout).toContain('# magpie local review: detached HEAD against main')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('runHandoff refuses a PR-mode run with exit 2', async () => {
|
|
276
|
+
await writeFile(join(runDir, 'target.json'), JSON.stringify({ mode: 'pr', root: repo }))
|
|
277
|
+
const exit = await runHandoff(runDir)
|
|
278
|
+
expect(exit).toBe(2)
|
|
279
|
+
expect(stderr).toContain('magpie: handoff')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
test('runHandoff returns 4 when findings.final.json is missing', async () => {
|
|
283
|
+
await writeLocalRun()
|
|
284
|
+
await rm(join(runDir, 'findings.final.json'))
|
|
285
|
+
expect(await runHandoff(runDir)).toBe(4)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('runHandoff returns 4 when local.json is missing', async () => {
|
|
289
|
+
await writeLocalRun()
|
|
290
|
+
await rm(join(runDir, 'local.json'))
|
|
291
|
+
expect(await runHandoff(runDir)).toBe(4)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test('runHandoff reports a failed recompute as stale', async () => {
|
|
295
|
+
await writeLocalRun()
|
|
296
|
+
const meta = JSON.parse(await readFile(join(runDir, 'local.json'), 'utf8'))
|
|
297
|
+
await writeFile(join(runDir, 'local.json'), JSON.stringify({ ...meta, baseRef: 'no-such-ref' }))
|
|
298
|
+
expect(await runHandoff(runDir)).toBe(0)
|
|
299
|
+
expect(stdout).toContain(WARNING)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('runHandoff reports a diff-source.json without diffSha256 as stale', async () => {
|
|
303
|
+
await writeLocalRun()
|
|
304
|
+
const source = JSON.parse(await readFile(join(runDir, 'diff-source.json'), 'utf8'))
|
|
305
|
+
delete source.diffSha256
|
|
306
|
+
await writeFile(join(runDir, 'diff-source.json'), JSON.stringify(source))
|
|
307
|
+
expect(await runHandoff(runDir)).toBe(0)
|
|
308
|
+
expect(stdout).toContain(WARNING)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('runHandoff still prints, stale, when the review root is gone', async () => {
|
|
312
|
+
await writeLocalRun()
|
|
313
|
+
const gone = await mkdtemp(join(tmpdir(), 'magpie-handoff-gone-'))
|
|
314
|
+
await rm(gone, { recursive: true, force: true })
|
|
315
|
+
await writeFile(join(runDir, 'target.json'), JSON.stringify({ mode: 'local', root: gone }))
|
|
316
|
+
expect(await runHandoff(runDir)).toBe(0)
|
|
317
|
+
expect(stdout).toContain(WARNING)
|
|
318
|
+
expect(stderr).toContain('magpie: handoff: could not recompute the diff: ')
|
|
319
|
+
})
|
|
@@ -23,6 +23,20 @@ test('listRuns picks up active and archived runs', async () => {
|
|
|
23
23
|
expect(ids).toEqual(['pr-1-100', 'pr-2-200.archived-300'])
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
+
test('listRuns picks up active and archived local runs, including dotted and dashed slugs', async () => {
|
|
27
|
+
await mkdir(join(home, 'local-feature-x-100'))
|
|
28
|
+
await mkdir(join(home, 'local-release-1.2_fix-200.archived-300'))
|
|
29
|
+
await mkdir(join(home, 'local-detached-400'))
|
|
30
|
+
await mkdir(join(home, 'local--500'))
|
|
31
|
+
const runs = await listRuns(home)
|
|
32
|
+
const byId = Object.fromEntries(runs.map((r) => [r.id, r.archived]))
|
|
33
|
+
expect(byId).toEqual({
|
|
34
|
+
'local-feature-x-100': false,
|
|
35
|
+
'local-release-1.2_fix-200.archived-300': true,
|
|
36
|
+
'local-detached-400': false,
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
26
40
|
test('cleanupRun deletes the matching directory', async () => {
|
|
27
41
|
await mkdir(join(home, 'pr-1-100'))
|
|
28
42
|
const exit = await cleanupRun(home, 'pr-1-100')
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { diffSha256, localBranchDiff, localBranchMeta } from '../local-diff.ts'
|
|
6
|
+
|
|
7
|
+
let repo: string
|
|
8
|
+
|
|
9
|
+
async function sh(cwd: string, ...cmd: string[]): Promise<string> {
|
|
10
|
+
const proc = Bun.spawn(cmd, { cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
11
|
+
const out = await new Response(proc.stdout).text()
|
|
12
|
+
const exit = await proc.exited
|
|
13
|
+
if (exit !== 0) {
|
|
14
|
+
const err = await new Response(proc.stderr).text()
|
|
15
|
+
throw new Error(`${cmd.join(' ')} exit ${exit}: ${err}`)
|
|
16
|
+
}
|
|
17
|
+
return out
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
repo = await mkdtemp(join(tmpdir(), 'magpie-local-diff-'))
|
|
22
|
+
await sh(repo, 'git', 'init', '-q', '-b', 'main')
|
|
23
|
+
await sh(repo, 'git', 'config', 'user.email', 't@t.t')
|
|
24
|
+
await sh(repo, 'git', 'config', 'user.name', 'Tess Tester')
|
|
25
|
+
await sh(repo, 'git', 'config', 'commit.gpgsign', 'false')
|
|
26
|
+
await writeFile(join(repo, 'a.txt'), 'one\n')
|
|
27
|
+
await writeFile(join(repo, 'b.txt'), 'bee\n')
|
|
28
|
+
await writeFile(join(repo, '.gitignore'), 'ignored.log\n')
|
|
29
|
+
await sh(repo, 'git', 'add', '.')
|
|
30
|
+
await sh(repo, 'git', 'commit', '-q', '-m', 'init')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
await rm(repo, { recursive: true, force: true })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
async function makeFeatureBranch(): Promise<void> {
|
|
38
|
+
await sh(repo, 'git', 'checkout', '-q', '-b', 'feat/x')
|
|
39
|
+
await writeFile(join(repo, 'a.txt'), 'one\ntwo\n')
|
|
40
|
+
await sh(repo, 'git', 'commit', '-q', '-am', 'add two', '-m', 'because two beats one')
|
|
41
|
+
await writeFile(join(repo, 'b.txt'), 'bee\nsting\n')
|
|
42
|
+
await writeFile(join(repo, 'new.txt'), 'fresh\n')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('localBranchDiff includes committed, uncommitted and untracked changes', async () => {
|
|
46
|
+
await makeFeatureBranch()
|
|
47
|
+
const mainSha = (await sh(repo, 'git', 'rev-parse', 'main')).trim()
|
|
48
|
+
const r = await localBranchDiff({ gitBin: 'git', repoPath: repo, baseRef: 'main' })
|
|
49
|
+
if (!r.ok) throw new Error(r.error)
|
|
50
|
+
expect(r.diff).toContain('diff --git a/a.txt b/a.txt')
|
|
51
|
+
expect(r.diff).toContain('+two')
|
|
52
|
+
expect(r.diff).toContain('diff --git a/b.txt b/b.txt')
|
|
53
|
+
expect(r.diff).toContain('+sting')
|
|
54
|
+
expect(r.diff).toContain('new file mode')
|
|
55
|
+
expect(r.diff).toContain('+++ b/new.txt')
|
|
56
|
+
expect(r.mergeBase).toBe(mainSha)
|
|
57
|
+
expect(r.baseRef).toBe('main')
|
|
58
|
+
expect(r.head).toBe((await sh(repo, 'git', 'rev-parse', 'HEAD')).trim())
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('localBranchDiff fails naming an unresolvable base ref', async () => {
|
|
62
|
+
await makeFeatureBranch()
|
|
63
|
+
const r = await localBranchDiff({ gitBin: 'git', repoPath: repo, baseRef: 'nope' })
|
|
64
|
+
expect(r.ok).toBe(false)
|
|
65
|
+
if (!r.ok) expect(r.error).toContain('nope')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('localBranchDiff defaults to origin/HEAD and fails naming it without a remote', async () => {
|
|
69
|
+
await makeFeatureBranch()
|
|
70
|
+
const r = await localBranchDiff({ gitBin: 'git', repoPath: repo })
|
|
71
|
+
expect(r.ok).toBe(false)
|
|
72
|
+
if (!r.ok) expect(r.error).toContain('origin/HEAD')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('localBranchDiff fails when the branch matches its base and the tree is clean', async () => {
|
|
76
|
+
await sh(repo, 'git', 'checkout', '-q', '-b', 'feat/x')
|
|
77
|
+
const r = await localBranchDiff({ gitBin: 'git', repoPath: repo, baseRef: 'main' })
|
|
78
|
+
expect(r.ok).toBe(false)
|
|
79
|
+
if (!r.ok) expect(r.error).toContain('nothing to review against main')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('localBranchDiff leaves gitignored files out', async () => {
|
|
83
|
+
await makeFeatureBranch()
|
|
84
|
+
await writeFile(join(repo, 'ignored.log'), 'noise\n')
|
|
85
|
+
const r = await localBranchDiff({ gitBin: 'git', repoPath: repo, baseRef: 'main' })
|
|
86
|
+
if (!r.ok) throw new Error(r.error)
|
|
87
|
+
expect(r.diff).not.toContain('ignored.log')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('localBranchDiff leaves the index and working tree untouched', async () => {
|
|
91
|
+
await makeFeatureBranch()
|
|
92
|
+
const statusBefore = await sh(repo, 'git', 'status', '--porcelain')
|
|
93
|
+
const cachedBefore = await sh(repo, 'git', 'diff', '--cached')
|
|
94
|
+
const r = await localBranchDiff({ gitBin: 'git', repoPath: repo, baseRef: 'main' })
|
|
95
|
+
expect(r.ok).toBe(true)
|
|
96
|
+
expect(await sh(repo, 'git', 'status', '--porcelain')).toBe(statusBefore)
|
|
97
|
+
expect(await sh(repo, 'git', 'diff', '--cached')).toBe(cachedBefore)
|
|
98
|
+
expect(statusBefore).toBe(' M b.txt\n?? new.txt\n')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
async function metaFor(intentFile: string | null) {
|
|
102
|
+
const d = await localBranchDiff({ gitBin: 'git', repoPath: repo, baseRef: 'main' })
|
|
103
|
+
if (!d.ok) throw new Error(d.error)
|
|
104
|
+
return localBranchMeta({
|
|
105
|
+
gitBin: 'git',
|
|
106
|
+
repoPath: repo,
|
|
107
|
+
baseRef: d.baseRef,
|
|
108
|
+
mergeBase: d.mergeBase,
|
|
109
|
+
head: d.head,
|
|
110
|
+
intentFile,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
test('localBranchMeta describes the branch, dirtiness, author and commits', async () => {
|
|
115
|
+
await makeFeatureBranch()
|
|
116
|
+
const r = await metaFor('/tmp/intent.md')
|
|
117
|
+
if (!r.ok) throw new Error(r.error)
|
|
118
|
+
expect(r.meta.branch).toBe('feat/x')
|
|
119
|
+
expect(r.meta.dirty).toBe(true)
|
|
120
|
+
expect(r.meta.author).toBe('Tess Tester')
|
|
121
|
+
expect(r.meta.intentFile).toBe('/tmp/intent.md')
|
|
122
|
+
expect(r.meta.commits).toHaveLength(1)
|
|
123
|
+
expect(r.meta.commits[0]?.headline).toBe('add two')
|
|
124
|
+
expect(r.meta.commits[0]?.body).toBe('because two beats one')
|
|
125
|
+
expect(r.meta.commits[0]?.oid).toBe((await sh(repo, 'git', 'rev-parse', 'HEAD')).trim())
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('localBranchMeta reports a null branch on a detached HEAD', async () => {
|
|
129
|
+
await makeFeatureBranch()
|
|
130
|
+
await sh(repo, 'git', 'checkout', '-q', '--detach')
|
|
131
|
+
const r = await metaFor(null)
|
|
132
|
+
if (!r.ok) throw new Error(r.error)
|
|
133
|
+
expect(r.meta.branch).toBeNull()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('localBranchMeta fails naming user.name when it is unset', async () => {
|
|
137
|
+
await makeFeatureBranch()
|
|
138
|
+
const d = await localBranchDiff({ gitBin: 'git', repoPath: repo, baseRef: 'main' })
|
|
139
|
+
if (!d.ok) throw new Error(d.error)
|
|
140
|
+
await sh(repo, 'git', 'config', '--unset', 'user.name')
|
|
141
|
+
const saved = {
|
|
142
|
+
GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL,
|
|
143
|
+
GIT_CONFIG_SYSTEM: process.env.GIT_CONFIG_SYSTEM,
|
|
144
|
+
HOME: process.env.HOME,
|
|
145
|
+
}
|
|
146
|
+
process.env.GIT_CONFIG_GLOBAL = '/dev/null'
|
|
147
|
+
process.env.GIT_CONFIG_SYSTEM = '/dev/null'
|
|
148
|
+
process.env.HOME = repo
|
|
149
|
+
try {
|
|
150
|
+
const r = await localBranchMeta({
|
|
151
|
+
gitBin: 'git',
|
|
152
|
+
repoPath: repo,
|
|
153
|
+
baseRef: d.baseRef,
|
|
154
|
+
mergeBase: d.mergeBase,
|
|
155
|
+
head: d.head,
|
|
156
|
+
intentFile: null,
|
|
157
|
+
})
|
|
158
|
+
expect(r.ok).toBe(false)
|
|
159
|
+
if (!r.ok) expect(r.error).toContain('user.name')
|
|
160
|
+
} finally {
|
|
161
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
162
|
+
if (v === undefined) delete process.env[k]
|
|
163
|
+
else process.env[k] = v
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('localBranchDiff keeps a/ b/ headers and real content despite user diff config', async () => {
|
|
169
|
+
await makeFeatureBranch()
|
|
170
|
+
await sh(repo, 'git', 'config', 'diff.noprefix', 'true')
|
|
171
|
+
await sh(repo, 'git', 'config', 'diff.external', '/usr/bin/true')
|
|
172
|
+
const r = await localBranchDiff({ gitBin: 'git', repoPath: repo, baseRef: 'main' })
|
|
173
|
+
if (!r.ok) throw new Error(r.error)
|
|
174
|
+
expect(r.diff).toContain('diff --git a/a.txt b/a.txt')
|
|
175
|
+
expect(r.diff).toContain('+two')
|
|
176
|
+
expect(r.diff).toContain('diff --git a/new.txt b/new.txt')
|
|
177
|
+
expect(r.diff).toContain('+fresh')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('localBranchDiff and localBranchMeta leave .git/index unwritten', async () => {
|
|
181
|
+
await makeFeatureBranch()
|
|
182
|
+
const future = new Date(Date.now() + 60_000)
|
|
183
|
+
await utimes(join(repo, 'a.txt'), future, future)
|
|
184
|
+
const indexPath = join(repo, '.git', 'index')
|
|
185
|
+
const past = new Date(Date.now() - 3_600_000)
|
|
186
|
+
await utimes(indexPath, past, past)
|
|
187
|
+
const before = await stat(indexPath)
|
|
188
|
+
const bytesBefore = await readFile(indexPath)
|
|
189
|
+
const d = await localBranchDiff({ gitBin: 'git', repoPath: repo, baseRef: 'main' })
|
|
190
|
+
if (!d.ok) throw new Error(d.error)
|
|
191
|
+
const m = await localBranchMeta({
|
|
192
|
+
gitBin: 'git',
|
|
193
|
+
repoPath: repo,
|
|
194
|
+
baseRef: d.baseRef,
|
|
195
|
+
mergeBase: d.mergeBase,
|
|
196
|
+
head: d.head,
|
|
197
|
+
intentFile: null,
|
|
198
|
+
})
|
|
199
|
+
expect(m.ok).toBe(true)
|
|
200
|
+
expect((await stat(indexPath)).mtimeMs).toBe(before.mtimeMs)
|
|
201
|
+
expect(Buffer.compare(await readFile(indexPath), bytesBefore)).toBe(0)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('localBranchDiff leaves .git/index unwritten when a tracked file is only stat-dirty', async () => {
|
|
205
|
+
await makeFeatureBranch()
|
|
206
|
+
const y2030 = new Date('2030-01-01T00:00:00Z')
|
|
207
|
+
// .gitignore is unchanged since the merge base, so only its stat info moves.
|
|
208
|
+
await utimes(join(repo, '.gitignore'), y2030, y2030)
|
|
209
|
+
const indexPath = join(repo, '.git', 'index')
|
|
210
|
+
const past = new Date(Date.now() - 3_600_000)
|
|
211
|
+
await utimes(indexPath, past, past)
|
|
212
|
+
const before = await stat(indexPath)
|
|
213
|
+
const bytesBefore = await readFile(indexPath)
|
|
214
|
+
const r = await localBranchDiff({ gitBin: 'git', repoPath: repo, baseRef: 'main' })
|
|
215
|
+
expect(r.ok).toBe(true)
|
|
216
|
+
expect((await stat(indexPath)).mtimeMs).toBe(before.mtimeMs)
|
|
217
|
+
expect(Buffer.compare(await readFile(indexPath), bytesBefore)).toBe(0)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('localBranchDiff includes a same-size edit made in the same second as the last index write', async () => {
|
|
221
|
+
// Pinning a.txt and the index to one whole second reproduces the racy case
|
|
222
|
+
// without waiting on the clock; trustctime=false because ctime cannot be set.
|
|
223
|
+
await sh(repo, 'git', 'config', 'core.trustctime', 'false')
|
|
224
|
+
const second = new Date('2020-01-01T00:00:00Z')
|
|
225
|
+
await utimes(join(repo, 'a.txt'), second, second)
|
|
226
|
+
await sh(repo, 'git', 'update-index', '--refresh')
|
|
227
|
+
await writeFile(join(repo, 'a.txt'), 'two\n')
|
|
228
|
+
await utimes(join(repo, 'a.txt'), second, second)
|
|
229
|
+
await utimes(join(repo, '.git', 'index'), second, second)
|
|
230
|
+
const r = await localBranchDiff({ gitBin: 'git', repoPath: repo, baseRef: 'main' })
|
|
231
|
+
if (!r.ok) throw new Error(r.error)
|
|
232
|
+
expect(r.diff).toContain('-one')
|
|
233
|
+
expect(r.diff).toContain('+two')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test('localBranchDiff writes non-ASCII paths unquoted', async () => {
|
|
237
|
+
await writeFile(join(repo, 'naïve.txt'), 'old\n')
|
|
238
|
+
await sh(repo, 'git', 'add', 'naïve.txt')
|
|
239
|
+
await sh(repo, 'git', 'commit', '-q', '-m', 'add naive')
|
|
240
|
+
await makeFeatureBranch()
|
|
241
|
+
await writeFile(join(repo, 'naïve.txt'), 'new\n')
|
|
242
|
+
await writeFile(join(repo, 'café.txt'), 'latte\n')
|
|
243
|
+
const r = await localBranchDiff({ gitBin: 'git', repoPath: repo, baseRef: 'main' })
|
|
244
|
+
if (!r.ok) throw new Error(r.error)
|
|
245
|
+
expect(r.diff).toContain('diff --git a/café.txt b/café.txt')
|
|
246
|
+
expect(r.diff).toContain('diff --git a/naïve.txt b/naïve.txt')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('localBranchMeta lists commits up to the given head, not HEAD', async () => {
|
|
250
|
+
await makeFeatureBranch()
|
|
251
|
+
const d = await localBranchDiff({ gitBin: 'git', repoPath: repo, baseRef: 'main' })
|
|
252
|
+
if (!d.ok) throw new Error(d.error)
|
|
253
|
+
const r = await localBranchMeta({
|
|
254
|
+
gitBin: 'git',
|
|
255
|
+
repoPath: repo,
|
|
256
|
+
baseRef: d.baseRef,
|
|
257
|
+
mergeBase: d.mergeBase,
|
|
258
|
+
head: d.mergeBase,
|
|
259
|
+
intentFile: null,
|
|
260
|
+
})
|
|
261
|
+
if (!r.ok) throw new Error(r.error)
|
|
262
|
+
expect(r.meta.commits).toEqual([])
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('localBranchDiff refuses a repoPath that is not the repository root', async () => {
|
|
266
|
+
await makeFeatureBranch()
|
|
267
|
+
await mkdir(join(repo, 'sub'))
|
|
268
|
+
const r = await localBranchDiff({ gitBin: 'git', repoPath: join(repo, 'sub'), baseRef: 'main' })
|
|
269
|
+
expect(r.ok).toBe(false)
|
|
270
|
+
if (!r.ok) expect(r.error).toContain(join(repo, 'sub'))
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('diffSha256 hashes the UTF-8 bytes as lowercase hex', () => {
|
|
274
|
+
expect(diffSha256('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad')
|
|
275
|
+
})
|
|
@@ -32,6 +32,34 @@ test('resolveRunDir returns the latest run when no id given', async () => {
|
|
|
32
32
|
expect(r.archived).toBe(false)
|
|
33
33
|
})
|
|
34
34
|
|
|
35
|
+
test('resolveRunDir skips a newer archived local run when no id given', async () => {
|
|
36
|
+
await makeRun('pr-1-1000.archived-2000', 0)
|
|
37
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
38
|
+
await makeRun('local-release-1.2-rc-2000.archived-3000', 0)
|
|
39
|
+
const r = await resolveRunDir(undefined, home)
|
|
40
|
+
expect(r).toEqual({
|
|
41
|
+
id: 'pr-1-1000.archived-2000',
|
|
42
|
+
path: join(home, 'pr-1-1000.archived-2000'),
|
|
43
|
+
archived: true,
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('resolveRunDir rejects an explicit local run id', async () => {
|
|
48
|
+
await makeRun('local-feature-x-2000.archived-3000', 0)
|
|
49
|
+
await expect(resolveRunDir('local-feature-x-2000.archived-3000', home)).rejects.toThrow(
|
|
50
|
+
'local-feature-x-2000.archived-3000 is a local run; local runs have no report',
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('resolveRunDir rejects a path to a run whose target is local', async () => {
|
|
55
|
+
await makeRun('custom-run', 0)
|
|
56
|
+
const path = join(home, 'custom-run')
|
|
57
|
+
await Bun.write(join(path, 'target.json'), JSON.stringify({ mode: 'local', root: '/repo' }))
|
|
58
|
+
await expect(resolveRunDir(path, home)).rejects.toThrow(
|
|
59
|
+
`${path} is a local run; local runs have no report`,
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
|
|
35
63
|
test('resolveRunDir picks an archived run as latest if it is newest', async () => {
|
|
36
64
|
await makeRun('pr-1-1000', 0)
|
|
37
65
|
await new Promise((r) => setTimeout(r, 20))
|