@7n/test 0.3.0 → 0.4.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 +18 -0
- package/package.json +1 -1
- package/src/coverage/coverage.mjs +315 -0
- package/src/fix-tests.mjs +146 -0
- package/src/run.mjs +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0] - 2026-06-26
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Додано `fix-tests.mjs`: виявляє падаючі unit-тести через vitest JSON reporter і викликає pi-агента для їх виправлення. Інтегровано в основний пайплайн `run.mjs` — якщо coverage порожня після першої ітерації, автоматично запускається фаза виправлення тестів.
|
|
8
|
+
|
|
9
|
+
## [0.3.2] - 2026-06-26
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Виправлено відсутність `src/coverage/coverage.mjs` у npm-пакеті: директорія ігнорувалась git через паттерн `**/coverage/` у `.gitignore`. Додано виняток `!npm/src/coverage/`.
|
|
14
|
+
|
|
15
|
+
## [0.3.1] - 2026-06-26
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- fix: unignore npm/src/coverage/ (was blocked by **/coverage/ in .gitignore)
|
|
20
|
+
|
|
3
21
|
## [0.3.0] - 2026-06-26
|
|
4
22
|
|
|
5
23
|
### Changed
|
package/package.json
CHANGED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Канонічна команда `n coverage`: збирає метрики покриття + мутаційного
|
|
3
|
+
* тестування з усіх провайдерів, чиє правило активне в `.n-cursor.json#rules`,
|
|
4
|
+
* агрегує та записує COVERAGE.md у корінь проєкту.
|
|
5
|
+
*
|
|
6
|
+
* Discovery провайдерів — за `.n-cursor.json#rules`: для кожного `ruleId` зі
|
|
7
|
+
* списку шукаємо `<rulesDir>/<ruleId>/coverage/coverage.mjs` і динамічно
|
|
8
|
+
* імпортуємо. Якщо файлу немає — провайдер для цього правила відсутній (skip
|
|
9
|
+
* silently, не помилка).
|
|
10
|
+
*
|
|
11
|
+
* `rulesDir` резолвиться з `node_modules/@nitra/cursor/rules` у cwd проєкту.
|
|
12
|
+
* Override — через `opts.rulesDir` у `runCoverageSteps`.
|
|
13
|
+
*
|
|
14
|
+
* Лок — прямий виклик `withLock('coverage', steps)`.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync } from 'node:fs'
|
|
17
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { pathToFileURL } from 'node:url'
|
|
20
|
+
|
|
21
|
+
import { applyVerdicts } from '../coverage-classify/apply.mjs'
|
|
22
|
+
import { classify } from '../coverage-classify/index.mjs'
|
|
23
|
+
import { collectChangedFilesSince, resolveChangedBase } from '../scripts/lib/changed-files.mjs'
|
|
24
|
+
import { readNCursorConfigLite } from '../scripts/lib/read-n-cursor-config-lite.mjs'
|
|
25
|
+
import { withLock } from '../scripts/utils/with-lock.mjs'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Знаходить rulesDir @nitra/cursor у node_modules проєкту.
|
|
29
|
+
* @param {string} cwd корінь проєкту
|
|
30
|
+
* @returns {string|null} абсолютний шлях до rules/ або null
|
|
31
|
+
*/
|
|
32
|
+
function resolveDefaultRulesDir(cwd) {
|
|
33
|
+
const localPath = join(cwd, 'node_modules', '@nitra', 'cursor', 'rules')
|
|
34
|
+
if (existsSync(localPath)) return localPath
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Сума двох coverage-totals.
|
|
40
|
+
* @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} a перший subtotal
|
|
41
|
+
* @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} b другий subtotal
|
|
42
|
+
* @returns {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} сумарні lines/functions
|
|
43
|
+
*/
|
|
44
|
+
export function addCoverage(a, b) {
|
|
45
|
+
return {
|
|
46
|
+
lines: { covered: a.lines.covered + b.lines.covered, total: a.lines.total + b.lines.total },
|
|
47
|
+
functions: {
|
|
48
|
+
covered: a.functions.covered + b.functions.covered,
|
|
49
|
+
total: a.functions.total + b.functions.total
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Сума двох mutation-counts.
|
|
56
|
+
* @param {{caught:number,total:number}} a перший subtotal
|
|
57
|
+
* @param {{caught:number,total:number}} b другий subtotal
|
|
58
|
+
* @returns {{caught:number,total:number}} сумарні caught/total
|
|
59
|
+
*/
|
|
60
|
+
export function addMutation(a, b) {
|
|
61
|
+
return { caught: a.caught + b.caught, total: a.total + b.total }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Форматує covered/total як `XX.XX% (covered/total)`.
|
|
66
|
+
* @param {{covered:number,total:number}} metric метрика lines або functions
|
|
67
|
+
* @returns {string} відформатований рядок для таблиці COVERAGE.md
|
|
68
|
+
*/
|
|
69
|
+
export function formatCoverage({ covered, total }) {
|
|
70
|
+
const percent = total === 0 ? '—' : `${((covered / total) * 100).toFixed(2)}%`
|
|
71
|
+
return `${percent} (${covered}/${total})`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Форматує мутаційний score як `XX.XX%`.
|
|
76
|
+
* @param {{caught:number,total:number}} metric агрегований mutation score
|
|
77
|
+
* @returns {string} відформатований score або прочерк
|
|
78
|
+
*/
|
|
79
|
+
export function formatScore({ caught, total }) {
|
|
80
|
+
return total === 0 ? '—' : `${((caught / total) * 100).toFixed(2)}%`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Рендерить таблицю покриття + мутаційного тестування як Markdown.
|
|
85
|
+
* Якщо будь-який рядок містить непустий `survived`, додає секцію
|
|
86
|
+
* `## Вцілілі мутанти` з JSON-блоком для coverage-fix.
|
|
87
|
+
* Якщо `allowedGaps` непустий, додає секцію `## Allowed gaps` з таблицею
|
|
88
|
+
* verdict/confidence/reason для кожного LLM-класифікованого мутанта.
|
|
89
|
+
* Без timestamp, щоб git diff рухався лише при зміні метрик.
|
|
90
|
+
* @param {Array<{area:string, coverage:{lines:{covered:number,total:number},functions:{covered:number,total:number}}, mutation:{caught:number,total:number}, survived?: Array<{file:string,line:number,col:number,mutantType:string,original:string,replacement:string}>}>} rows рядки провайдерів
|
|
91
|
+
* @param {Array<{file:string, mutant:{line:number,col:number,mutantType:string,original:string,replacement:string}, verdict:{verdict:string,confidence:number,reason:string}}>} [allowedGaps] мутанти виключені класифікатором
|
|
92
|
+
* @returns {string} Markdown з заголовком `# Coverage`
|
|
93
|
+
*/
|
|
94
|
+
export function renderMarkdown(rows, allowedGaps = []) {
|
|
95
|
+
const lines = [
|
|
96
|
+
'# Coverage',
|
|
97
|
+
'',
|
|
98
|
+
'| Область | Рядки | Функції | Вбито мутацій | Score |',
|
|
99
|
+
'| --- | --- | --- | --- | --- |'
|
|
100
|
+
]
|
|
101
|
+
for (const row of rows) {
|
|
102
|
+
lines.push(
|
|
103
|
+
`| ${row.area} | ${formatCoverage(row.coverage.lines)} | ${formatCoverage(row.coverage.functions)} | ` +
|
|
104
|
+
`${row.mutation.caught}/${row.mutation.total} | ${formatScore(row.mutation)} |`
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const allSurvived = rows.flatMap(r => r.survived ?? [])
|
|
109
|
+
if (allSurvived.length > 0) {
|
|
110
|
+
lines.push('', '## Вцілілі мутанти', '', '```json', JSON.stringify(allSurvived, null, 2), '```')
|
|
111
|
+
// Зрозуміла для людини таблиця
|
|
112
|
+
for (const group of allSurvived) {
|
|
113
|
+
lines.push('', `### ${group.file}`, '', '| Рядок | Оригінал | Заміна | Тип |', '| --- | --- | --- | --- |')
|
|
114
|
+
for (const m of group.mutants) {
|
|
115
|
+
lines.push(`| ${m.line} | \`${m.original}\` | \`${m.replacement}\` | ${m.mutantType} |`)
|
|
116
|
+
}
|
|
117
|
+
if (group.exampleTest) {
|
|
118
|
+
lines.push(
|
|
119
|
+
'',
|
|
120
|
+
`**Приклад тесту** (\`${group.exampleTest.testFile}\`):`,
|
|
121
|
+
'',
|
|
122
|
+
'```js',
|
|
123
|
+
group.exampleTest.code ?? '',
|
|
124
|
+
'```'
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
if (group.recommendationText) {
|
|
128
|
+
lines.push('', '**Що треба протестувати:**', '', group.recommendationText)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (allowedGaps.length > 0) {
|
|
134
|
+
// Group allowed gaps by file
|
|
135
|
+
const gapsByFile = new Map()
|
|
136
|
+
for (const gap of allowedGaps) {
|
|
137
|
+
if (!gapsByFile.has(gap.file)) gapsByFile.set(gap.file, [])
|
|
138
|
+
gapsByFile.get(gap.file).push(gap)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
lines.push(
|
|
142
|
+
'',
|
|
143
|
+
'## Allowed gaps',
|
|
144
|
+
'',
|
|
145
|
+
`> LLM-класифікатор виключив ${allowedGaps.length} survived мутант(ів) зі знаменника mutation score.`,
|
|
146
|
+
'> Категорії: equivalent (поведінково еквівалентний), defensive (impossible state), glue/wrapper (integration test покриває).'
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
for (const [file, gaps] of gapsByFile) {
|
|
150
|
+
lines.push(
|
|
151
|
+
'',
|
|
152
|
+
`### ${file}`,
|
|
153
|
+
'',
|
|
154
|
+
'| Line | Mutant | Verdict | Confidence | Reason |',
|
|
155
|
+
'| --- | --- | --- | --- | --- |'
|
|
156
|
+
)
|
|
157
|
+
for (const { mutant, verdict } of gaps) {
|
|
158
|
+
const sanitizedReason = verdict.reason.replaceAll('|', String.raw`\|`).replaceAll('\n', ' ')
|
|
159
|
+
lines.push(
|
|
160
|
+
`| ${mutant.line} | \`${mutant.original}\` → \`${mutant.replacement}\` | ${verdict.verdict} | ${verdict.confidence.toFixed(2)} | ${sanitizedReason} |`
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return `${lines.join('\n')}\n`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Завантажує provider-модуль з `<rulesDir>/<ruleId>/coverage/coverage.mjs`.
|
|
171
|
+
* Повертає null коли:
|
|
172
|
+
* - файлу немає (rule без coverage-провайдера),
|
|
173
|
+
* - файл існує, але не експортує `detect` + `collect` як функції.
|
|
174
|
+
* @param {string} rulesDir корінь rules/ (з @nitra/cursor або override)
|
|
175
|
+
* @param {string} ruleId id правила з `.n-cursor.json#rules`
|
|
176
|
+
* @returns {Promise<{detect:(cwd:string)=>Promise<boolean>, collect:(cwd:string)=>Promise<Array<object>>}|null>} provider-модуль або null
|
|
177
|
+
*/
|
|
178
|
+
async function loadProvider(rulesDir, ruleId) {
|
|
179
|
+
const providerPath = join(rulesDir, ruleId, 'coverage', 'coverage.mjs')
|
|
180
|
+
if (!existsSync(providerPath)) return null
|
|
181
|
+
const mod = await import(pathToFileURL(providerPath).href)
|
|
182
|
+
if (typeof mod.detect !== 'function' || typeof mod.collect !== 'function') return null
|
|
183
|
+
return mod
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Будує підсумковий рядок «Разом» через сумування всіх coverage/mutation.
|
|
188
|
+
* @param {Array<{area:string, coverage:object, mutation:object}>} rows рядки провайдерів без totals
|
|
189
|
+
* @returns {{area:string, coverage:object, mutation:{caught:number,total:number}}} агрегований рядок «Разом»
|
|
190
|
+
*/
|
|
191
|
+
function buildTotalsRow(rows) {
|
|
192
|
+
let totalCoverage = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
|
|
193
|
+
let totalMutation = { caught: 0, total: 0 }
|
|
194
|
+
for (const row of rows) {
|
|
195
|
+
totalCoverage = addCoverage(totalCoverage, row.coverage)
|
|
196
|
+
totalMutation = addMutation(totalMutation, row.mutation)
|
|
197
|
+
}
|
|
198
|
+
return { area: '**Разом**', coverage: totalCoverage, mutation: totalMutation }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Читає `.n-cursor.json#coverage.classifyConfidenceThreshold` (default 1.1 — rollout mode).
|
|
203
|
+
* @param {string} cwd корінь проєкту
|
|
204
|
+
* @returns {Promise<number>} threshold у [0, 1.1]
|
|
205
|
+
*/
|
|
206
|
+
async function readClassifyThreshold(cwd) {
|
|
207
|
+
try {
|
|
208
|
+
const raw = await readFile(join(cwd, '.n-cursor.json'), 'utf8')
|
|
209
|
+
const parsed = JSON.parse(raw)
|
|
210
|
+
const t = parsed?.coverage?.classifyConfidenceThreshold
|
|
211
|
+
return typeof t === 'number' && Number.isFinite(t) ? t : 1.1
|
|
212
|
+
} catch {
|
|
213
|
+
return 1.1
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Резолвить scope змінених файлів для `--changed`-режиму.
|
|
219
|
+
* @param {string} cwd корінь проєкту
|
|
220
|
+
* @returns {{base: string|null, files: string[]}} base-ref і relative-posix список змінених файлів
|
|
221
|
+
*/
|
|
222
|
+
export function resolveChangedScope(cwd) {
|
|
223
|
+
const base = resolveChangedBase(cwd)
|
|
224
|
+
return { base, files: collectChangedFilesSince(base, cwd) }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Виконує coverage-pipeline: discovery провайдерів за `.n-cursor.json#rules`,
|
|
229
|
+
* detect+collect для кожного, агрегація, запис COVERAGE.md.
|
|
230
|
+
* @param {{cwd?:string, rulesDir?:string, fix?:boolean, changed?:boolean}} [opts]
|
|
231
|
+
* @returns {Promise<number>} exit code (0 OK, 1 помилка)
|
|
232
|
+
*/
|
|
233
|
+
export async function runCoverageSteps(opts = {}) {
|
|
234
|
+
const cwd = opts.cwd ?? process.cwd()
|
|
235
|
+
const rulesDir = opts.rulesDir ?? resolveDefaultRulesDir(cwd)
|
|
236
|
+
|
|
237
|
+
if (!rulesDir) {
|
|
238
|
+
console.error(
|
|
239
|
+
'✗ Не знайдено @nitra/cursor у node_modules. ' +
|
|
240
|
+
'Встанови @nitra/cursor як devDependency або передай rulesDir через opts.'
|
|
241
|
+
)
|
|
242
|
+
return 1
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const config = await readNCursorConfigLite(cwd)
|
|
246
|
+
const scope = opts.changed ? resolveChangedScope(cwd) : null
|
|
247
|
+
const collectOpts = scope ? { changedFiles: scope.files, base: scope.base } : {}
|
|
248
|
+
const rows = []
|
|
249
|
+
|
|
250
|
+
for (const ruleId of config.rules) {
|
|
251
|
+
if (config.disableRules.includes(ruleId)) continue
|
|
252
|
+
const provider = await loadProvider(rulesDir, ruleId)
|
|
253
|
+
if (!provider) continue
|
|
254
|
+
if (!(await provider.detect(cwd))) continue
|
|
255
|
+
console.log(`→ ${ruleId} coverage…`)
|
|
256
|
+
rows.push(...(await provider.collect(cwd, collectOpts)))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (rows.length === 0) {
|
|
260
|
+
if (opts.changed) {
|
|
261
|
+
console.log('✓ coverage --changed: немає змінених файлів у scope провайдерів — пропускаю')
|
|
262
|
+
return 0
|
|
263
|
+
}
|
|
264
|
+
console.error('✗ Жодного провайдера покриття не знайдено для активних правил у .n-cursor.json#rules')
|
|
265
|
+
return 1
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (opts.changed) {
|
|
269
|
+
console.log('✓ coverage --changed: змінені файли перевірено')
|
|
270
|
+
return 0
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// LLM-класифікація survived мутантів (graceful skip без API key)
|
|
274
|
+
const allSurvived = rows.flatMap(r => r.survived ?? [])
|
|
275
|
+
let augmentedRows = rows
|
|
276
|
+
let allowedGaps = []
|
|
277
|
+
if (allSurvived.length > 0) {
|
|
278
|
+
const verdicts = await classify(allSurvived, cwd)
|
|
279
|
+
if (verdicts.length > 0) {
|
|
280
|
+
const threshold = await readClassifyThreshold(cwd)
|
|
281
|
+
const applied = applyVerdicts(rows, verdicts, threshold)
|
|
282
|
+
augmentedRows = applied.rows
|
|
283
|
+
allowedGaps = applied.allowedGaps
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (augmentedRows.filter(r => r.area !== '**Разом**').length > 1) {
|
|
288
|
+
augmentedRows.push(buildTotalsRow(augmentedRows.filter(r => r.area !== '**Разом**')))
|
|
289
|
+
}
|
|
290
|
+
const md = renderMarkdown(augmentedRows, allowedGaps)
|
|
291
|
+
// Stryker disable next-line StringLiteral: equivalent – writeFile(path, str, '') behaves identically to 'utf8' in Node/Bun
|
|
292
|
+
await writeFile(join(cwd, 'COVERAGE.md'), md, 'utf8')
|
|
293
|
+
console.log('✓ COVERAGE.md')
|
|
294
|
+
|
|
295
|
+
if (opts.fix) {
|
|
296
|
+
const { fixSurvivedMutants } = await import(new URL('../coverage-fix.mjs', import.meta.url).href)
|
|
297
|
+
await fixSurvivedMutants(allSurvived, cwd)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return 0
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* CLI entrypoint для `n coverage [--fix] [--changed]`.
|
|
305
|
+
* @param {{fix?:boolean, changed?:boolean, rulesDir?:string}} [opts]
|
|
306
|
+
* @returns {Promise<number>} exit code
|
|
307
|
+
*/
|
|
308
|
+
export async function runCoverageCli(opts = {}) {
|
|
309
|
+
const code = await withLock('coverage', () => runCoverageSteps(opts))
|
|
310
|
+
if (code === 0 && opts.fix) {
|
|
311
|
+
console.log('\n♻️ Повторний coverage після агента…\n')
|
|
312
|
+
return withLock('coverage', () => runCoverageSteps({ fix: false, changed: opts.changed, rulesDir: opts.rulesDir }))
|
|
313
|
+
}
|
|
314
|
+
return code
|
|
315
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fix-tests: виявляє падаючі unit-тести і викликає pi-агента для їх виправлення.
|
|
3
|
+
*
|
|
4
|
+
* Порядок роботи:
|
|
5
|
+
* 1. getFailingTests(dir) — запускає `bunx vitest run --reporter=json` і повертає
|
|
6
|
+
* список падаючих файлів з повідомленнями про помилки.
|
|
7
|
+
* 2. fixFailingTests(dir, opts) — якщо є падіння, будує prompt і викликає pi CLI
|
|
8
|
+
* в агентному режимі; перевіряє результат повторним getFailingTests.
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from 'node:child_process'
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
12
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import { join, relative } from 'node:path'
|
|
15
|
+
import { env } from 'node:process'
|
|
16
|
+
|
|
17
|
+
const MODEL = env.N_CURSOR_FIX_TESTS_MODEL ?? env.N_CLOUD_MAX_MODEL ?? ''
|
|
18
|
+
const MAX_ERRORS_PER_FILE = 5
|
|
19
|
+
const MAX_ERROR_LINES = 10
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Runs vitest in JSON mode and returns failing test files with error messages.
|
|
23
|
+
* @param {string} dir project root
|
|
24
|
+
* @returns {Promise<Array<{file: string, errors: string[]}>>}
|
|
25
|
+
*/
|
|
26
|
+
export async function getFailingTests(dir) {
|
|
27
|
+
const tmpDir = await mkdtemp(join(tmpdir(), '7n-fix-'))
|
|
28
|
+
const outputFile = join(tmpDir, 'results.json')
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
spawnSync(
|
|
32
|
+
'bunx',
|
|
33
|
+
['vitest', 'run', '--reporter=json', `--outputFile=${outputFile}`, '--passWithNoTests'],
|
|
34
|
+
{ cwd: dir, stdio: 'inherit', env }
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if (!existsSync(outputFile)) return []
|
|
38
|
+
|
|
39
|
+
let data
|
|
40
|
+
try {
|
|
41
|
+
data = JSON.parse(readFileSync(outputFile, 'utf8'))
|
|
42
|
+
} catch {
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (data.testResults ?? [])
|
|
47
|
+
.filter(r => r.status === 'failed')
|
|
48
|
+
.map(r => ({
|
|
49
|
+
file: relative(dir, r.testFilePath),
|
|
50
|
+
errors: (r.assertionResults ?? [])
|
|
51
|
+
.filter(a => a.status === 'failed')
|
|
52
|
+
.slice(0, MAX_ERRORS_PER_FILE)
|
|
53
|
+
.map(a => {
|
|
54
|
+
const name = [...(a.ancestorTitles ?? []), a.title].join(' > ')
|
|
55
|
+
const msg = (a.failureMessages?.[0] ?? '').split('\n').slice(0, MAX_ERROR_LINES).join('\n')
|
|
56
|
+
return `${name}:\n${msg}`
|
|
57
|
+
})
|
|
58
|
+
}))
|
|
59
|
+
.filter(f => !f.file.startsWith('..') && f.errors.length > 0)
|
|
60
|
+
} finally {
|
|
61
|
+
await rm(tmpDir, { recursive: true, force: true }).catch(() => {})
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Builds a prompt for pi agent to fix failing tests.
|
|
67
|
+
* @param {Array<{file: string, errors: string[]}>} failures
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function buildFixTestsPrompt(failures) {
|
|
71
|
+
const fileList = failures
|
|
72
|
+
.map(({ file, errors }) => {
|
|
73
|
+
const errBlock = errors.join('\n\n')
|
|
74
|
+
return `### \`${file}\`\n\`\`\`\n${errBlock}\n\`\`\``
|
|
75
|
+
})
|
|
76
|
+
.join('\n\n')
|
|
77
|
+
|
|
78
|
+
return [
|
|
79
|
+
'Виправ падаючі unit-тести. Для кожного файлу:',
|
|
80
|
+
'1. Прочитай тест-файл і source-файл що він тестує',
|
|
81
|
+
'2. Запусти: `bunx vitest run <testFile>` — переконайся в точній помилці',
|
|
82
|
+
'3. Виправ ЛИШЕ тест-файл — source-файли не чіпай',
|
|
83
|
+
'4. Запусти тест ще раз — переконайся що зелений',
|
|
84
|
+
'',
|
|
85
|
+
'## Правила:',
|
|
86
|
+
'- Міняй виключно тест-файли',
|
|
87
|
+
'- Зберігай структуру тестів (describe/it/expect)',
|
|
88
|
+
'- Якщо тест перевіряє неіснуючий API — адаптуй до реального',
|
|
89
|
+
'- НЕ видаляй тести — лише виправляй',
|
|
90
|
+
'- Якщо тест тестує видалений функціонал — закоментуй з поясненням чому',
|
|
91
|
+
'',
|
|
92
|
+
'## Падаючі файли та помилки:',
|
|
93
|
+
'',
|
|
94
|
+
fileList
|
|
95
|
+
].join('\n')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Calls pi CLI as agent (live output) to fix failing tests.
|
|
100
|
+
* @param {string} prompt
|
|
101
|
+
* @param {string} model provider/model-id або '' для pi-дефолту
|
|
102
|
+
* @param {string} cwd
|
|
103
|
+
*/
|
|
104
|
+
function callPi(prompt, model, cwd) {
|
|
105
|
+
const modelArgs = model ? ['--model', model] : []
|
|
106
|
+
spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session'], {
|
|
107
|
+
cwd,
|
|
108
|
+
stdio: 'inherit',
|
|
109
|
+
timeout: 900_000
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Detects and fixes failing tests using a pi agent.
|
|
115
|
+
* Returns immediately with count=0 if all tests are already passing.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} dir project root
|
|
118
|
+
* @param {{
|
|
119
|
+
* failures?: Array<{file: string, errors: string[]}>,
|
|
120
|
+
* callPi?: (prompt: string, model: string, cwd: string) => void
|
|
121
|
+
* }} [opts]
|
|
122
|
+
* @returns {Promise<{count: number, fixed: number, remaining: number}>}
|
|
123
|
+
*/
|
|
124
|
+
export async function fixFailingTests(dir, opts = {}) {
|
|
125
|
+
const callPiFn = opts.callPi ?? callPi
|
|
126
|
+
const failures = opts.failures ?? (await getFailingTests(dir))
|
|
127
|
+
|
|
128
|
+
if (failures.length === 0) return { count: 0, fixed: 0, remaining: 0 }
|
|
129
|
+
|
|
130
|
+
console.log(`\n🔧 Виправляю ${failures.length} падаючих test-файлів (pi agent)...\n`)
|
|
131
|
+
for (const f of failures) {
|
|
132
|
+
console.log(` • ${f.file} (${f.errors.length} помилок)`)
|
|
133
|
+
}
|
|
134
|
+
console.log()
|
|
135
|
+
|
|
136
|
+
const prompt = buildFixTestsPrompt(failures)
|
|
137
|
+
callPiFn(prompt, MODEL, dir)
|
|
138
|
+
|
|
139
|
+
const after = await getFailingTests(dir)
|
|
140
|
+
const fixed = failures.length - after.length
|
|
141
|
+
|
|
142
|
+
if (fixed > 0) console.log(`✓ Виправлено: ${fixed}/${failures.length} файлів`)
|
|
143
|
+
if (after.length > 0) console.log(`⚠ Залишились падати: ${after.length} файлів`)
|
|
144
|
+
|
|
145
|
+
return { count: failures.length, fixed, remaining: after.length }
|
|
146
|
+
}
|
package/src/run.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { measureCoveragePerFile, getUncoveredFiles } from './coverage-per-file.mjs'
|
|
15
15
|
import { assessNeed } from './assess-need.mjs'
|
|
16
16
|
import { generateTests } from './gen-tests.mjs'
|
|
17
|
+
import { fixFailingTests, getFailingTests } from './fix-tests.mjs'
|
|
17
18
|
import { runCoverageSteps } from './coverage/coverage.mjs'
|
|
18
19
|
import { withLock } from './scripts/utils/with-lock.mjs'
|
|
19
20
|
|
|
@@ -35,6 +36,12 @@ export async function runAutoTest(dir, opts = {}) {
|
|
|
35
36
|
|
|
36
37
|
const allFiles = await measureCoveragePerFile(dir)
|
|
37
38
|
if (allFiles.length === 0) {
|
|
39
|
+
const failures = await getFailingTests(dir)
|
|
40
|
+
if (failures.length > 0) {
|
|
41
|
+
console.log(`\n── ${failures.length} тестів падають — виправляю (pi agent) ──\n`)
|
|
42
|
+
const { remaining } = await fixFailingTests(dir, { failures })
|
|
43
|
+
if (remaining === 0) continue
|
|
44
|
+
}
|
|
38
45
|
console.log('⚠ Vitest coverage не повернула даних — перевір налаштування vitest.')
|
|
39
46
|
break
|
|
40
47
|
}
|