@7n/test 0.9.0 → 0.10.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 +22 -0
- package/package.json +4 -2
- package/src/assess-need.mjs +4 -1
- package/src/classify-exports.mjs +108 -0
- package/src/coverage-classify/docs/apply.md +28 -0
- package/src/coverage-classify/docs/cache.md +34 -0
- package/src/coverage-classify/docs/index.md +40 -0
- package/src/coverage-classify/docs/prompt.md +30 -0
- package/src/coverage-classify/docs/verdict-schema.md +29 -0
- package/src/coverage-fix-extract.mjs +10 -10
- package/src/coverage-per-file.mjs +68 -43
- package/src/docs/assess-need.md +31 -0
- package/src/docs/classify-exports.md +30 -0
- package/src/docs/coverage-fix-extract.md +35 -0
- package/src/docs/coverage-fix.md +31 -0
- package/src/docs/coverage-per-file.md +34 -0
- package/src/docs/fix-tests.md +32 -0
- package/src/docs/gen-tests.md +38 -0
- package/src/docs/index.md +34 -0
- package/src/docs/run.md +33 -0
- package/src/fix-tests.mjs +233 -87
- package/src/gen-tests.mjs +1102 -49
- package/src/lib/ast-analyze.mjs +287 -0
- package/src/lib/docs/ast-analyze.md +45 -0
- package/src/lib/docs/index.md +14 -0
- package/src/lib/docs/pi-client.md +29 -0
- package/src/lib/docs/runtime-probe.md +36 -0
- package/src/lib/docs/vitest-shim.md +31 -0
- package/src/lib/pi-client.mjs +18 -3
- package/src/lib/runtime-probe.mjs +390 -0
- package/src/lib/vitest-shim.mjs +73 -0
- package/src/run.mjs +3 -1
- package/src/scripts/lib/changed-files.mjs +5 -5
- package/src/scripts/lib/docs/changed-files.md +32 -0
- package/src/scripts/lib/docs/read-n-cursor-config-lite.md +39 -0
- package/src/scripts/utils/docs/index.md +13 -0
- package/src/scripts/utils/docs/lock-cache-dir.md +32 -0
- package/src/scripts/utils/docs/with-lock.md +38 -0
- package/src/scripts/utils/docs/worktree-fingerprint.md +34 -0
- package/src/scripts/utils/lock-cache-dir.mjs +1 -1
- package/src/scripts/utils/with-lock.mjs +2 -3
package/src/gen-tests.mjs
CHANGED
|
@@ -1,50 +1,1026 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Test generation via pi SDK
|
|
2
|
+
* Test generation via pi SDK with per-export tiered routing.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Strategy:
|
|
5
|
+
* 1. Classify each export: trivial/simple → local pi model first, complex → cloud.
|
|
6
|
+
* 2. Generate a shared header (imports, mocks, setup) via cloud.
|
|
7
|
+
* 3. Generate per-export describe() blocks routed by complexity.
|
|
8
|
+
* 4. Validate local-generated blocks; fall back to cloud on anti-patterns.
|
|
9
|
+
* 5. Merge header + blocks → write test file.
|
|
10
|
+
*
|
|
11
|
+
* Local model is selected via opts.localModel or N_LOCAL_MIN_MODEL env var.
|
|
12
|
+
* All calls (local and cloud) go through the pi SDK — no direct HTTP to omlx.
|
|
13
|
+
* Falls back to single-file cloud generation when no local model is configured
|
|
14
|
+
* or when extractExportsWithComplexity() returns no exports.
|
|
7
15
|
*/
|
|
8
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
9
|
-
import {
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { spawnSync } from 'node:child_process'
|
|
18
|
+
import { join, relative, dirname } from 'node:path'
|
|
10
19
|
import { callText } from './lib/pi-client.mjs'
|
|
20
|
+
import { resolveVitestRun } from './lib/vitest-shim.mjs'
|
|
21
|
+
import { extractExportsWithComplexity } from './classify-exports.mjs'
|
|
22
|
+
import { analyzeModule } from './lib/ast-analyze.mjs'
|
|
23
|
+
import { probeModule, probeFetchCalls, probeTimeVariants, probeHelpers } from './lib/runtime-probe.mjs'
|
|
24
|
+
import { checkEnv } from '@nitra/check-env'
|
|
11
25
|
|
|
12
26
|
const MAX_SRC_BYTES = 6000
|
|
13
27
|
|
|
14
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Reads a source file and trims it to the prompt budget.
|
|
30
|
+
* @param {string} absPath absolute source path
|
|
31
|
+
* @returns {string} source snippet or empty string when unavailable
|
|
32
|
+
*/
|
|
33
|
+
function readSourceSnippet(absPath) {
|
|
34
|
+
if (!existsSync(absPath)) return ''
|
|
35
|
+
const content = readFileSync(absPath, 'utf8')
|
|
36
|
+
return content.length > MAX_SRC_BYTES ? `${content.slice(0, MAX_SRC_BYTES)}\n...(truncated)` : content
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Static regex constants (prefer-static-regex)
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const FILE_EXT_RE = /\.[^.]+$/
|
|
44
|
+
const CODE_BLOCK_RE = /```(?:js|javascript|mjs|ts)?\n([\s\S]*?)```/
|
|
45
|
+
const FRONTMATTER_RE = /^---[\s\S]*?---\n/
|
|
46
|
+
const REQUIRE_CALL_RE = /\brequire\s*\(/
|
|
47
|
+
const JEST_ACCESS_RE = /\bjest\./
|
|
48
|
+
const AS_VI_MOCK_RE = /\bas\s+vi\.Mock/
|
|
49
|
+
const AS_JEST_MOCK_RE = /\bas\s+jest\.Mock/
|
|
50
|
+
const MOCK_TYPE_RE = /:\s*\w*Mock\b/
|
|
51
|
+
const FETCH_CALL_RE = /\bfetch\s*\(/
|
|
52
|
+
const TIME_DEPS_RE = /\bnew\s+Date\b|\bgetHours\b|\bgetDay\b|\bgetMinutes\b|\bDate\.now\b/
|
|
53
|
+
const VITEST_FAIL_RE = /Failed Tests|FAIL /
|
|
54
|
+
const MEMORY_ERROR_RE = /memory guard|memory limit|prefill would require/i
|
|
55
|
+
const EXPECTED_LINE_RE = /Expected:\s+"([^"]+)"/
|
|
56
|
+
const RECEIVED_LINE_RE = /Received:\s+"([^"]+)"/
|
|
57
|
+
const TO_CONTAIN_RE = /to contain '([^='\s]+)=([^']+)'/
|
|
58
|
+
const NOT_CONTAIN_RE = /not to contain '([^']+)'/
|
|
59
|
+
const FLAG_CONTAIN_RE = /to contain '([^'=]+)=true'/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @callback PiCallFn
|
|
63
|
+
* @param {string} prompt LLM prompt
|
|
64
|
+
* @param {{cwd?: string, model?: string}} [options] call options
|
|
65
|
+
* @returns {Promise<string>} LLM response text
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @callback GenerateOneFn
|
|
70
|
+
* @param {{file: string, pct: number, reason: string}} fileInfo file coverage info
|
|
71
|
+
* @param {string} dir project root
|
|
72
|
+
* @returns {Promise<string|null>} written test path or null
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @typedef {object} GenerateTestsOptions
|
|
77
|
+
* @property {PiCallFn} [callText] - Custom cloud caller.
|
|
78
|
+
* @property {string|null} [localModel] - Local model id; null forces cloud-only mode.
|
|
79
|
+
* @property {GenerateOneFn} [generateOne] - Custom single-file generator.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Helpers shared across strategies
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extracts names of exported symbols from JS/TS source.
|
|
88
|
+
* @param {string} content source text
|
|
89
|
+
* @returns {string[]} exported symbol names
|
|
90
|
+
*/
|
|
91
|
+
function extractExports(content) {
|
|
92
|
+
return Array.from(content.matchAll(/^export\s+(?:async\s+)?(?:const|function|class|let)\s+(\w+)/gm), m => m[1])
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Detects top-level function calls that run as side-effects on module load. */
|
|
96
|
+
const TOP_LEVEL_CALL_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*\(/m
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Test file candidates relative to source file.
|
|
100
|
+
* Primary: tests/ subdirectory (per n-test.mdc convention).
|
|
101
|
+
* @param {string} file relative source path
|
|
102
|
+
* @returns {string[]} candidate test file paths
|
|
103
|
+
*/
|
|
15
104
|
function testCandidates(file) {
|
|
16
|
-
const base = file.replace(
|
|
105
|
+
const base = file.replace(FILE_EXT_RE, '')
|
|
17
106
|
const lastSlash = base.lastIndexOf('/')
|
|
18
107
|
const name = lastSlash === -1 ? base : base.slice(lastSlash + 1)
|
|
19
108
|
const dir = lastSlash === -1 ? '' : base.slice(0, lastSlash)
|
|
20
|
-
|
|
109
|
+
const testsDir = dir ? `${dir}/tests` : 'tests'
|
|
110
|
+
return [`${testsDir}/${name}.test.mjs`, `${base}.test.mjs`, `${base}.test.js`]
|
|
21
111
|
}
|
|
22
112
|
|
|
23
113
|
/**
|
|
24
|
-
* Extracts the first
|
|
25
|
-
* @param {string} text
|
|
26
|
-
* @returns {string}
|
|
114
|
+
* Extracts the first fenced JS block from LLM text output.
|
|
115
|
+
* @param {string} text LLM output
|
|
116
|
+
* @returns {string} extracted code or empty string
|
|
27
117
|
*/
|
|
28
118
|
function extractCode(text) {
|
|
29
|
-
const
|
|
30
|
-
|
|
119
|
+
const m = CODE_BLOCK_RE.exec(text)
|
|
120
|
+
if (m) return m[1].trim()
|
|
121
|
+
const start = text.indexOf('```')
|
|
122
|
+
if (start === -1) return ''
|
|
123
|
+
const bodyStart = text.indexOf('\n', start)
|
|
124
|
+
if (bodyStart === -1) return ''
|
|
125
|
+
const end = text.indexOf('\n```', bodyStart + 1)
|
|
126
|
+
if (end === -1) return ''
|
|
127
|
+
return text.slice(bodyStart + 1, end).trim()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Checks whether source text calls or declares a symbol by name.
|
|
132
|
+
* @param {string} text source text to search
|
|
133
|
+
* @param {string} name symbol name to look for
|
|
134
|
+
* @returns {boolean} true when the symbol is invoked
|
|
135
|
+
*/
|
|
136
|
+
function hasInvocation(text, name) {
|
|
137
|
+
return text.includes(`${name}(`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Finds the project's n-test.mdc rules by walking up from dir (max 4 levels).
|
|
142
|
+
* @param {string} dir project root
|
|
143
|
+
* @returns {string|null} rules text or null
|
|
144
|
+
*/
|
|
145
|
+
export function findTestRules(dir) {
|
|
146
|
+
let current = dir
|
|
147
|
+
for (let i = 0; i < 4; i++) {
|
|
148
|
+
const candidate = join(current, '.cursor/rules/n-test.mdc')
|
|
149
|
+
if (existsSync(candidate)) {
|
|
150
|
+
return readFileSync(candidate, 'utf8').replace(FRONTMATTER_RE, '').trim()
|
|
151
|
+
}
|
|
152
|
+
const parent = dirname(current)
|
|
153
|
+
if (parent === current) break
|
|
154
|
+
current = parent
|
|
155
|
+
}
|
|
156
|
+
return null
|
|
31
157
|
}
|
|
32
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Resolves importPath and testFilePath for a source file relative to its test.
|
|
161
|
+
* @param {string} file relative source path
|
|
162
|
+
* @returns {{testFilePath: string, importPath: string}} resolved paths
|
|
163
|
+
*/
|
|
164
|
+
function resolveTestPaths(file) {
|
|
165
|
+
const testFilePath = testCandidates(file)[0]
|
|
166
|
+
const testDir = dirname(testFilePath)
|
|
167
|
+
const rel = relative(testDir, file)
|
|
168
|
+
const importPath = rel.startsWith('.') ? rel : `./${rel}`
|
|
169
|
+
return { testFilePath, importPath }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Validation helpers for per-export blocks
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Returns true when a LLM-generated describe block passes basic quality checks.
|
|
178
|
+
* Used to decide whether to accept local output or escalate to cloud.
|
|
179
|
+
* @param {string} block describe block text
|
|
180
|
+
* @returns {boolean} true when the block looks valid
|
|
181
|
+
*/
|
|
182
|
+
function isValidBlock(block) {
|
|
183
|
+
if (!block?.trim()) return false
|
|
184
|
+
if (!block.includes('describe(')) return false
|
|
185
|
+
if (REQUIRE_CALL_RE.test(block)) return false
|
|
186
|
+
if (JEST_ACCESS_RE.test(block)) return false
|
|
187
|
+
if (AS_VI_MOCK_RE.test(block)) return false
|
|
188
|
+
if (AS_JEST_MOCK_RE.test(block)) return false
|
|
189
|
+
return !MOCK_TYPE_RE.test(block)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Combines a shared header with individual describe() blocks.
|
|
194
|
+
* Strips stray import lines that models sometimes add to blocks.
|
|
195
|
+
* @param {string} header shared test header
|
|
196
|
+
* @param {string[]} blocks describe blocks
|
|
197
|
+
* @returns {string | null} merged file content or `null`
|
|
198
|
+
*/
|
|
199
|
+
function mergeBlocks(header, blocks) {
|
|
200
|
+
if (!header?.trim()) return null
|
|
201
|
+
const clean = blocks
|
|
202
|
+
.filter(Boolean)
|
|
203
|
+
.map(b =>
|
|
204
|
+
b
|
|
205
|
+
.replaceAll(/^import\s[^;]+;?\n?/gm, '')
|
|
206
|
+
.replaceAll(/^\/\/ .+\n/gm, '')
|
|
207
|
+
.trim()
|
|
208
|
+
)
|
|
209
|
+
.filter(Boolean)
|
|
210
|
+
if (clean.length === 0) return null
|
|
211
|
+
return [header.trim(), '', ...clean].join('\n\n')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Prompt builders
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @typedef {object} HeaderPromptOptions
|
|
220
|
+
* @property {string} file source file path
|
|
221
|
+
* @property {string} testFilePath generated test file path
|
|
222
|
+
* @property {string} importPath import path from test to source
|
|
223
|
+
* @property {boolean} hasSideEffects whether source runs top-level side effects
|
|
224
|
+
* @property {string} content source snippet
|
|
225
|
+
* @property {string[]} exports exported symbol names
|
|
226
|
+
* @property {string|null} testRules project test rules
|
|
227
|
+
* @property {object|null} astInfo static AST analysis result
|
|
228
|
+
*/
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Builds the header prompt. Accepts pre-computed AST info so mock shapes are
|
|
232
|
+
* derived deterministically — the LLM only fills in vi.stubEnv calls.
|
|
233
|
+
* @param {HeaderPromptOptions} opts header prompt options
|
|
234
|
+
* @returns {string} header prompt text
|
|
235
|
+
*/
|
|
236
|
+
function buildHeaderPrompt(opts) {
|
|
237
|
+
const { file, testFilePath, importPath, hasSideEffects, content, exports, testRules, astInfo } = opts
|
|
238
|
+
const mockLines = astInfo?.externalMocks?.map(m => m.mockLine) ?? []
|
|
239
|
+
const envReads = astInfo?.envReads ?? []
|
|
240
|
+
const usesFetch = astInfo?.usesFetch ?? FETCH_CALL_RE.test(content)
|
|
241
|
+
|
|
242
|
+
const importLine = hasSideEffects
|
|
243
|
+
? `const { ${exports.join(', ')} } = await import("${importPath}")`
|
|
244
|
+
: `import { ${exports.join(', ')} } from "${importPath}"`
|
|
245
|
+
|
|
246
|
+
const envStubHints = envReads.length
|
|
247
|
+
? envReads.map(k => ` vi.stubEnv("${k}", "test_value")`)
|
|
248
|
+
: [' // vi.stubEnv("KEY", "value") — для env-змінних що читає модуль']
|
|
249
|
+
|
|
250
|
+
const hasTimeDependency = TIME_DEPS_RE.test(content)
|
|
251
|
+
const timerHints = hasTimeDependency
|
|
252
|
+
? [' vi.useFakeTimers()', ' vi.setSystemTime(new Date("2024-01-01T02:00:00"))']
|
|
253
|
+
: [' // vi.useFakeTimers() + vi.setSystemTime(...) — якщо є new Date()']
|
|
254
|
+
|
|
255
|
+
const template = [
|
|
256
|
+
`import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"`,
|
|
257
|
+
'',
|
|
258
|
+
...(mockLines.length ? mockLines : ['// vi.mock("pkg", () => ({ fn: vi.fn() }))']),
|
|
259
|
+
'',
|
|
260
|
+
importLine,
|
|
261
|
+
...(usesFetch ? ['', 'const mockFetch = vi.fn()'] : []),
|
|
262
|
+
'',
|
|
263
|
+
'beforeEach(() => {',
|
|
264
|
+
' vi.clearAllMocks()',
|
|
265
|
+
...(usesFetch
|
|
266
|
+
? [
|
|
267
|
+
' vi.stubGlobal("fetch", mockFetch)',
|
|
268
|
+
' mockFetch.mockResolvedValue({ status: 200, json: async () => ({}) })'
|
|
269
|
+
]
|
|
270
|
+
: []),
|
|
271
|
+
...envStubHints,
|
|
272
|
+
...timerHints,
|
|
273
|
+
'})',
|
|
274
|
+
'',
|
|
275
|
+
'afterEach(() => {',
|
|
276
|
+
' vi.restoreAllMocks()',
|
|
277
|
+
' vi.unstubAllGlobals()',
|
|
278
|
+
' vi.unstubAllEnvs()',
|
|
279
|
+
' vi.useRealTimers()',
|
|
280
|
+
'})'
|
|
281
|
+
].join('\n')
|
|
282
|
+
|
|
283
|
+
const internalNote = astInfo?.internalNames?.length
|
|
284
|
+
? `Внутрішні (НЕ-exported, НЕ імітувати): ${astInfo.internalNames.join(', ')}`
|
|
285
|
+
: ''
|
|
286
|
+
|
|
287
|
+
return [
|
|
288
|
+
`Заповни template header для unit-тест файлу (без describe/it блоків).`,
|
|
289
|
+
`Тест-файл: \`${testFilePath}\` Source: \`${file}\``,
|
|
290
|
+
'',
|
|
291
|
+
'TEMPLATE — vi.mock рядки вже точні (з AST). Заповни лише vi.stubEnv і vi.useFakeTimers де потрібно:',
|
|
292
|
+
'```js',
|
|
293
|
+
template,
|
|
294
|
+
'```',
|
|
295
|
+
'',
|
|
296
|
+
'ПРАВИЛА:',
|
|
297
|
+
`- Імпортуй з \`${importPath}\` — НЕ змінюй розширення, НЕ підміняй цей модуль`,
|
|
298
|
+
`- Exports: ${exports.join(', ')}`,
|
|
299
|
+
...(internalNote ? [internalNote] : []),
|
|
300
|
+
'- vi.mock() factories вже прописані вище — НЕ додавай нових',
|
|
301
|
+
'Поверни лише код у ```js … ```',
|
|
302
|
+
...(testRules ? ['', '## Конвенції проєкту:', testRules] : []),
|
|
303
|
+
'',
|
|
304
|
+
`Source (${file}):`,
|
|
305
|
+
'```js',
|
|
306
|
+
content || '(недоступно)',
|
|
307
|
+
'```'
|
|
308
|
+
].join('\n')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Extracts the source of a top-level (non-exported) declaration by name.
|
|
313
|
+
* @param {string} content module source
|
|
314
|
+
* @param {string} name declaration name
|
|
315
|
+
* @returns {string|null} declaration source or null if not found
|
|
316
|
+
*/
|
|
317
|
+
function extractInternalSource(content, name) {
|
|
318
|
+
const prefixes = [`const ${name} =`, `let ${name} =`, `var ${name} =`, `function ${name}(`, `async function ${name}(`]
|
|
319
|
+
let start = -1
|
|
320
|
+
for (const prefix of prefixes) {
|
|
321
|
+
const direct = content.indexOf(prefix)
|
|
322
|
+
if (direct !== -1 && (start === -1 || direct < start)) start = direct
|
|
323
|
+
const lineStart = content.indexOf(`\n${prefix}`)
|
|
324
|
+
if (lineStart !== -1) {
|
|
325
|
+
const candidate = lineStart + 1
|
|
326
|
+
if (start === -1 || candidate < start) start = candidate
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (start === -1) return null
|
|
330
|
+
const after = content.slice(start)
|
|
331
|
+
const markers = ['\nconst ', '\nlet ', '\nvar ', '\nfunction ', '\nasync function ', '\nexport ']
|
|
332
|
+
let end = -1
|
|
333
|
+
for (const marker of markers) {
|
|
334
|
+
const idx = after.indexOf(marker)
|
|
335
|
+
if (idx !== -1 && (end === -1 || idx < end)) end = idx
|
|
336
|
+
}
|
|
337
|
+
return after.slice(0, end === -1 ? Math.min(after.length, 600) : end)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const COMPLEXITY_HINTS = {
|
|
341
|
+
trivial: 'константа, 1-2 прості перевірки',
|
|
342
|
+
simple: 'чиста функція',
|
|
343
|
+
complex: 'async/fetch/Date/env'
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Describes the generation budget implied by export complexity.
|
|
348
|
+
* @param {string} complexity export complexity bucket
|
|
349
|
+
* @returns {string} human-readable complexity hint
|
|
350
|
+
*/
|
|
351
|
+
function describeExportComplexity(complexity) {
|
|
352
|
+
return COMPLEXITY_HINTS[complexity] ?? COMPLEXITY_HINTS.complex
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Extracts the source fragment relevant to one export.
|
|
357
|
+
* @param {string} content module source
|
|
358
|
+
* @param {string} name exported symbol name
|
|
359
|
+
* @returns {string} narrowed source snippet
|
|
360
|
+
*/
|
|
361
|
+
function extractExportSnippet(content, name) {
|
|
362
|
+
const exportPrefixes = [
|
|
363
|
+
`export async function ${name}`,
|
|
364
|
+
`export function ${name}`,
|
|
365
|
+
`export async const ${name}`,
|
|
366
|
+
`export const ${name}`,
|
|
367
|
+
`export let ${name}`,
|
|
368
|
+
`export class ${name}`
|
|
369
|
+
]
|
|
370
|
+
let startAt = -1
|
|
371
|
+
let startLen = 0
|
|
372
|
+
for (const prefix of exportPrefixes) {
|
|
373
|
+
const direct = content.indexOf(prefix)
|
|
374
|
+
if (direct !== -1 && (startAt === -1 || direct < startAt)) {
|
|
375
|
+
startAt = direct
|
|
376
|
+
startLen = prefix.length
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (startAt === -1) return content
|
|
380
|
+
const after = content.slice(startAt + startLen)
|
|
381
|
+
const nextExport = after.indexOf('\nexport ')
|
|
382
|
+
const end = nextExport === -1 ? Math.min(after.length, 2000) : nextExport
|
|
383
|
+
return content.slice(startAt, startAt + startLen) + after.slice(0, end)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Formats runtime-probe results for the block prompt.
|
|
388
|
+
* @param {string} name export name
|
|
389
|
+
* @param {object} probeResults probe results by export name
|
|
390
|
+
* @returns {string[]} prompt lines
|
|
391
|
+
*/
|
|
392
|
+
function buildProbeLines(name, probeResults) {
|
|
393
|
+
const probeSection = probeResults?.[name]
|
|
394
|
+
if (Array.isArray(probeSection) && probeSection.length) {
|
|
395
|
+
return [
|
|
396
|
+
'',
|
|
397
|
+
`Реальні виходи \`${name}\` (runtime-probe — використовуй для expected, не вгадуй):`,
|
|
398
|
+
...probeSection.map(p => `- ${name}(${p.input}) → ${p.output}`)
|
|
399
|
+
]
|
|
400
|
+
}
|
|
401
|
+
if (probeSection?.constant !== undefined) {
|
|
402
|
+
return ['', `Реальне значення \`${name}\`: ${probeSection.constant}`]
|
|
403
|
+
}
|
|
404
|
+
return []
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Formats fetch capture results for the block prompt.
|
|
409
|
+
* @param {string} name export name
|
|
410
|
+
* @param {object} fetchProbe captured fetch calls by export name
|
|
411
|
+
* @returns {string[]} prompt lines
|
|
412
|
+
*/
|
|
413
|
+
function buildFetchLines(name, fetchProbe) {
|
|
414
|
+
const fetchCalls = fetchProbe?.[name]
|
|
415
|
+
return fetchCalls?.length
|
|
416
|
+
? [
|
|
417
|
+
'',
|
|
418
|
+
`Реальні fetch-виклики \`${name}\` (перехоплено під час probe — використовуй для assert URL):`,
|
|
419
|
+
...fetchCalls.map(c => `- args ${c.args} → fetch("${c.url}"${c.init ? ', init' : ''})`)
|
|
420
|
+
]
|
|
421
|
+
: []
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Formats time-variant probe results for the block prompt.
|
|
426
|
+
* @param {string} name export name
|
|
427
|
+
* @param {object} timeProbe time-variant probe results by export name
|
|
428
|
+
* @returns {string[]} prompt lines
|
|
429
|
+
*/
|
|
430
|
+
function buildTimeLines(name, timeProbe) {
|
|
431
|
+
const timeVariant = timeProbe?.[name]
|
|
432
|
+
return timeVariant
|
|
433
|
+
? [
|
|
434
|
+
'',
|
|
435
|
+
`Часова залежність \`${name}\` (виходи змінюються залежно від години):`,
|
|
436
|
+
...Object.entries(timeVariant).map(([h, v]) => `- ${h.toString().padStart(2, '0')}:00 → ${v}`)
|
|
437
|
+
]
|
|
438
|
+
: []
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Formats helper introspection results for the block prompt.
|
|
443
|
+
* @param {string} content module source
|
|
444
|
+
* @param {string} snippet narrowed source snippet
|
|
445
|
+
* @param {string[]} internalNames internal top-level names
|
|
446
|
+
* @param {object} helperProbe internal helper probe results by helper name
|
|
447
|
+
* @returns {string[]} prompt lines
|
|
448
|
+
*/
|
|
449
|
+
function buildHelperLines(content, snippet, internalNames, helperProbe) {
|
|
450
|
+
const usedHelpers = internalNames.filter(name => hasInvocation(snippet, name))
|
|
451
|
+
const helperSources = usedHelpers
|
|
452
|
+
.map(name => extractInternalSource(content, name))
|
|
453
|
+
.filter(Boolean)
|
|
454
|
+
.slice(0, 3)
|
|
455
|
+
const helperLines = usedHelpers.flatMap(name => {
|
|
456
|
+
const calls = helperProbe?.[name]
|
|
457
|
+
if (!calls?.length) return []
|
|
458
|
+
return [
|
|
459
|
+
'',
|
|
460
|
+
`Реальні виходи internal helper \`${name}\` (НЕ імітувати, лише розуміти):`,
|
|
461
|
+
...calls.slice(0, 4).map(c => `- ${name}(${JSON.stringify(c.params)}) → ${JSON.stringify(c.result)}`)
|
|
462
|
+
]
|
|
463
|
+
})
|
|
464
|
+
return helperSources.length
|
|
465
|
+
? [
|
|
466
|
+
'',
|
|
467
|
+
'Internal helpers (контекст для розуміння params/API — НЕ імітувати):',
|
|
468
|
+
'```js',
|
|
469
|
+
...helperSources,
|
|
470
|
+
'```',
|
|
471
|
+
...helperLines
|
|
472
|
+
]
|
|
473
|
+
: helperLines
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Builds the prompt for a single describe() block for one export.
|
|
478
|
+
* @param {object} opts block prompt options
|
|
479
|
+
* @param {{name: string, complexity: string}} opts.exp export metadata
|
|
480
|
+
* @param {string} opts.testFilePath generated test file path
|
|
481
|
+
* @param {string} opts.importPath import path from test to source
|
|
482
|
+
* @param {string} opts.content source snippet
|
|
483
|
+
* @param {string} opts.header generated shared test header
|
|
484
|
+
* @param {string|null} opts.testRules project test rules
|
|
485
|
+
* @param {object} opts.probeResults runtime probe results by export name
|
|
486
|
+
* @param {object} opts.fetchProbe captured fetch calls by export name
|
|
487
|
+
* @param {object} opts.timeProbe time variant probe results by export name
|
|
488
|
+
* @param {object} opts.helperProbe internal helper probe results by helper name
|
|
489
|
+
* @param {object|null} opts.astInfo static AST analysis result
|
|
490
|
+
* @returns {string} block prompt text
|
|
491
|
+
*/
|
|
492
|
+
function buildBlockPrompt(opts) {
|
|
493
|
+
const {
|
|
494
|
+
exp,
|
|
495
|
+
testFilePath,
|
|
496
|
+
importPath,
|
|
497
|
+
content,
|
|
498
|
+
header,
|
|
499
|
+
testRules,
|
|
500
|
+
probeResults,
|
|
501
|
+
fetchProbe,
|
|
502
|
+
timeProbe,
|
|
503
|
+
helperProbe,
|
|
504
|
+
astInfo
|
|
505
|
+
} = opts
|
|
506
|
+
const snippet = extractExportSnippet(content, exp.name)
|
|
507
|
+
const internalNames = astInfo?.internalNames ?? []
|
|
508
|
+
|
|
509
|
+
return [
|
|
510
|
+
`Тест-файл: \`${testFilePath}\` Source import: \`"${importPath}"\``,
|
|
511
|
+
'',
|
|
512
|
+
'Header вже написано (НЕ дублюй import/beforeEach/afterEach):',
|
|
513
|
+
'```js',
|
|
514
|
+
header,
|
|
515
|
+
'```',
|
|
516
|
+
'',
|
|
517
|
+
`Напиши ЛИШЕ \`describe("${exp.name}", () => { … })\` для \`${exp.name}\`.`,
|
|
518
|
+
`Складність: ${exp.complexity} — ${describeExportComplexity(exp.complexity)}`,
|
|
519
|
+
...buildProbeLines(exp.name, probeResults),
|
|
520
|
+
...buildFetchLines(exp.name, fetchProbe),
|
|
521
|
+
...buildTimeLines(exp.name, timeProbe),
|
|
522
|
+
...buildHelperLines(content, snippet, internalNames, helperProbe),
|
|
523
|
+
'',
|
|
524
|
+
'Правила (СУВОРО):',
|
|
525
|
+
'- Без import, без beforeEach — тільки describe',
|
|
526
|
+
'- ESM only (без require), vi.* (без jest.*)',
|
|
527
|
+
'- vi.mocked(fn) замість type-кастингу',
|
|
528
|
+
'- toBe для примітивів, toEqual для обʼєктів/масивів',
|
|
529
|
+
'- `describe()` callback НЕ може бути async — `await` тільки у top-level, `beforeAll(async()=>{})`, або `it(async()=>{})`',
|
|
530
|
+
"- НЕ використовуй vi.spyOn на ESM-exports — це неможливо (`Cannot spy on export`). Для перевірки виклику fetch використовуй `mockFetch.mock.calls[0][0]` (URL) та `mockFetch.mock.calls[0][1]` (init-об'єкт або undefined)",
|
|
531
|
+
'- fetch завжди викликається як `fetch(url, undefined)` — `toHaveBeenCalledWith` ПРОВАЛИТЬСЯ (2 аргументи). ЗАВЖДИ перевіряй URL через `expect(mockFetch.mock.calls[0][0]).toContain(pattern)` або `.toBe(url)` — НЕ `toHaveBeenCalledWith`',
|
|
532
|
+
'- `expect(str).stringContaining(x)` та `expect(str).not.stringContaining(x)` НЕ існують в Vitest — використовуй `expect(str).toContain(pattern)` та `expect(str).not.toContain(pattern)`',
|
|
533
|
+
'- НЕ створюй окремий mock-спай для тестованої функції — вона вже реальна. Стеж тільки за `mockFetch`',
|
|
534
|
+
'- `formData.get("document")` повертає `File` об\'єкт, НЕ рядок — для перевірки імені файлу: `formData.get("document").name`',
|
|
535
|
+
'- `vi.useFakeTimers()` БЕЗ `vi.setSystemTime(...)` заморожує час на ЗАРАЗ (поточна година) — якщо функція залежить від часу доби, обовʼязково встанови фіксований час: `vi.setSystemTime(new Date("2024-01-01T00:00:00"))` (північ, поза робочими годинами)',
|
|
536
|
+
'- При тестуванні params — перевіряй РЕАЛЬНІ назви полів з internal helpers (наприклад, `disable_notification`, НЕ `silent`)',
|
|
537
|
+
'- Поверни лише describe-блок у ```js … ```',
|
|
538
|
+
...(testRules ? ['', '## Конвенції:', testRules.slice(0, 1500)] : []),
|
|
539
|
+
'',
|
|
540
|
+
`Source (${exp.name}):`,
|
|
541
|
+
'```js',
|
|
542
|
+
snippet,
|
|
543
|
+
'```'
|
|
544
|
+
].join('\n')
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
// Block validation via real vitest run
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
const LOCAL_MAX_ATTEMPTS = 3
|
|
552
|
+
const CLOUD_MAX_ATTEMPTS = 10
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Runs a single describe block (merged with header) in vitest.
|
|
556
|
+
* Writes a temp file inside testDir so relative imports and vitest's include
|
|
557
|
+
* pattern both resolve correctly. Cleans up after the run.
|
|
558
|
+
* @param {string} header shared test file header
|
|
559
|
+
* @param {string} block describe block to validate
|
|
560
|
+
* @param {string} dir project root (cwd for vitest, for config resolution)
|
|
561
|
+
* @param {string} testDir directory where the real test file would live
|
|
562
|
+
* @returns {{ passed: boolean, errors: string }} vitest result
|
|
563
|
+
*/
|
|
564
|
+
function runBlock(header, block, dir, testDir) {
|
|
565
|
+
const code = mergeBlocks(header, [block])
|
|
566
|
+
if (!code) return { passed: false, errors: 'mergeBlocks failed' }
|
|
567
|
+
|
|
568
|
+
const { bin, configArgs } = resolveVitestRun(dir)
|
|
569
|
+
mkdirSync(testDir, { recursive: true })
|
|
570
|
+
const tmpFile = join(testDir, '.7n-validate.test.mjs')
|
|
571
|
+
try {
|
|
572
|
+
writeFileSync(tmpFile, code + '\n', 'utf8')
|
|
573
|
+
const result = spawnSync(
|
|
574
|
+
process.execPath,
|
|
575
|
+
[bin, 'run', ...configArgs, '--root', dir, '--reporter=verbose', tmpFile],
|
|
576
|
+
{ cwd: dir, encoding: 'utf8', timeout: 30_000, env: process.env }
|
|
577
|
+
)
|
|
578
|
+
if (result.status === 0) return { passed: true, errors: '' }
|
|
579
|
+
const out = (result.stdout ?? '') + (result.stderr ?? '')
|
|
580
|
+
// Extract only the failure section to keep context short
|
|
581
|
+
const lines = out.split('\n')
|
|
582
|
+
const failIdx = lines.findIndex(l => VITEST_FAIL_RE.test(l))
|
|
583
|
+
const relevant = failIdx === -1 ? out : lines.slice(failIdx).join('\n')
|
|
584
|
+
return { passed: false, errors: relevant.slice(0, 3000) }
|
|
585
|
+
} finally {
|
|
586
|
+
try {
|
|
587
|
+
rmSync(tmpFile)
|
|
588
|
+
} catch {
|
|
589
|
+
/* ignore if already gone */
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Detects common env/timer anti-patterns from vitest error output.
|
|
596
|
+
* Pattern-based, project-agnostic — parses Expected/Received from vitest output.
|
|
597
|
+
* Returns root-cause hints to inject into the retry prompt.
|
|
598
|
+
* @param {string} errors vitest error output
|
|
599
|
+
* @returns {string[]} hint messages
|
|
600
|
+
*/
|
|
601
|
+
function detectStaleRootCause(errors) {
|
|
602
|
+
const hints = []
|
|
603
|
+
|
|
604
|
+
// Extract structured Expected/Received lines from vitest output
|
|
605
|
+
const expectedMatch = errors.match(EXPECTED_LINE_RE)
|
|
606
|
+
const receivedMatch = errors.match(RECEIVED_LINE_RE)
|
|
607
|
+
const _expected = expectedMatch?.[1] ?? ''
|
|
608
|
+
const received = receivedMatch?.[1] ?? ''
|
|
609
|
+
|
|
610
|
+
// Pattern 1: expected param=X, but received param=test_value (global stub conflict)
|
|
611
|
+
// e.g. expected 'param=12345', received '...param=test_value...'
|
|
612
|
+
const toContainMatch = errors.match(TO_CONTAIN_RE)
|
|
613
|
+
if (toContainMatch && received.includes(`${toContainMatch[1]}=test_value`) && toContainMatch[2] !== 'test_value') {
|
|
614
|
+
const [, param, wantedVal] = toContainMatch
|
|
615
|
+
hints.push(
|
|
616
|
+
`ПРИЧИНА: "${param}=" у результаті має значення "test_value" (зі stubEnv у beforeEach), а не "${wantedVal}".`,
|
|
617
|
+
`Щоб перевірити конкретне значення — додай vi.stubEnv("ENV_KEY", "${wantedVal}") всередині it-блоку перед викликом.`,
|
|
618
|
+
`Або змінить assert на .toContain("${param}=test_value") якщо конкретне значення не важливе.`
|
|
619
|
+
)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Pattern 2: not.toContain(X) fails → X always present (global stub makes it so)
|
|
623
|
+
const notContainMatch = errors.match(NOT_CONTAIN_RE)
|
|
624
|
+
if (notContainMatch && received.includes(notContainMatch[1])) {
|
|
625
|
+
hints.push(
|
|
626
|
+
`ПРИЧИНА: "${notContainMatch[1]}" завжди присутній у результаті — швидше за все через глобальний vi.stubEnv у beforeEach.`,
|
|
627
|
+
`Видали цей тест або перевірте умову при якій "${notContainMatch[1]}" не з'являється.`
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Pattern 3: expected to contain 'flag=true' but absent → conditional/time-based logic
|
|
632
|
+
const flagMatch = errors.match(FLAG_CONTAIN_RE)
|
|
633
|
+
if (flagMatch && !received.includes(`${flagMatch[1]}=true`)) {
|
|
634
|
+
hints.push(
|
|
635
|
+
`ПРИЧИНА: "${flagMatch[1]}=true" не з'являється — це умовний параметр (залежить від часу, env або стану).`,
|
|
636
|
+
`Якщо залежить від часу — встанови фіксований час у it-блоці: vi.setSystemTime(new Date("2024-01-01T02:00:00")).`,
|
|
637
|
+
`Якщо залежить від env — stub відповідну змінну перед викликом.`
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return hints
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Wraps the original block prompt with vitest error feedback for a retry.
|
|
646
|
+
* When rootCauseHints are provided (stale error detected), injects them before the error.
|
|
647
|
+
* @param {string} originalPrompt initial block prompt
|
|
648
|
+
* @param {string} prevBlock previous describe block
|
|
649
|
+
* @param {string} errors vitest error output
|
|
650
|
+
* @param {number} attempt current attempt number
|
|
651
|
+
* @param {string[]} rootCauseHints stale-error diagnostic hints
|
|
652
|
+
* @returns {string} retry prompt text
|
|
653
|
+
*/
|
|
654
|
+
function buildRetryPrompt(originalPrompt, prevBlock, errors, attempt, rootCauseHints = []) {
|
|
655
|
+
const hintsSection = rootCauseHints.length
|
|
656
|
+
? ['', '### Аналіз причини (не ігноруй — помилка повторюється):', ...rootCauseHints.map(h => `- ${h}`)]
|
|
657
|
+
: []
|
|
658
|
+
|
|
659
|
+
return [
|
|
660
|
+
originalPrompt,
|
|
661
|
+
'',
|
|
662
|
+
'---',
|
|
663
|
+
`## Спроба ${attempt}: попередній блок не пройшов vitest`,
|
|
664
|
+
'',
|
|
665
|
+
'Твій попередній варіант:',
|
|
666
|
+
'```js',
|
|
667
|
+
prevBlock,
|
|
668
|
+
'```',
|
|
669
|
+
'',
|
|
670
|
+
'Помилки vitest:',
|
|
671
|
+
'```',
|
|
672
|
+
errors,
|
|
673
|
+
'```',
|
|
674
|
+
...hintsSection,
|
|
675
|
+
'',
|
|
676
|
+
'Поверни виправлений describe-блок у ```js … ```'
|
|
677
|
+
].join('\n')
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const STALE_THRESHOLD = 2
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Builds retry diagnostics for repeated vitest failures.
|
|
684
|
+
* @param {string|null} lastErrors previous error text
|
|
685
|
+
* @param {string|null} prevErrorSig previous stable error signature
|
|
686
|
+
* @param {number} staleCount current repeated-error counter
|
|
687
|
+
* @param {string} label display name for logging
|
|
688
|
+
* @returns {{staleCount: number, prevErrorSig: string|null, rootCauseHints: string[]}} retry diagnostics
|
|
689
|
+
*/
|
|
690
|
+
function buildRetryDiagnostics(lastErrors, prevErrorSig, staleCount, label) {
|
|
691
|
+
const errorSig = lastErrors?.slice(0, 120) ?? null
|
|
692
|
+
const nextStaleCount = errorSig && errorSig === prevErrorSig ? staleCount + 1 : 0
|
|
693
|
+
const rootCauseHints = nextStaleCount >= STALE_THRESHOLD ? detectStaleRootCause(lastErrors ?? '') : []
|
|
694
|
+
if (rootCauseHints.length) {
|
|
695
|
+
console.log(` ${label} ⚡ stale error (${nextStaleCount}x) — root cause hints injected`)
|
|
696
|
+
}
|
|
697
|
+
return { staleCount: nextStaleCount, prevErrorSig: errorSig, rootCauseHints }
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Converts an LLM exception into loop-control state.
|
|
702
|
+
* @param {Error} error caught LLM error
|
|
703
|
+
* @param {number} attempt current attempt number
|
|
704
|
+
* @param {number} maxAttempts retry limit
|
|
705
|
+
* @param {string} label display name for logging
|
|
706
|
+
* @returns {{stop: boolean, reset: boolean, lastErrors: string|null}} loop-control state
|
|
707
|
+
*/
|
|
708
|
+
function resolveLoopCallFailure(error, attempt, maxAttempts, label) {
|
|
709
|
+
console.log(` ${label} ✗ LLM error (спроба ${attempt}): ${error.message}`)
|
|
710
|
+
if (attempt >= maxAttempts) return { stop: true, reset: false, lastErrors: null }
|
|
711
|
+
if (MEMORY_ERROR_RE.test(error.message)) return { stop: false, reset: true, lastErrors: null }
|
|
712
|
+
return { stop: false, reset: false, lastErrors: `LLM error: ${error.message}` }
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Generates a describe block with a run → feedback loop.
|
|
717
|
+
* On each vitest failure feeds the error back into the next prompt.
|
|
718
|
+
* @param {string} basePrompt initial block prompt
|
|
719
|
+
* @param {PiCallFn} callFn LLM caller (async)
|
|
720
|
+
* @param {object} callOpts options forwarded to callFn
|
|
721
|
+
* @param {string} header shared test file header
|
|
722
|
+
* @param {string} dir project root (cwd for vitest)
|
|
723
|
+
* @param {string} testDir directory where real test file lives (for relative imports)
|
|
724
|
+
* @param {string} label display name for logging
|
|
725
|
+
* @param {number} maxAttempts cap on retry iterations
|
|
726
|
+
* @param {string|null} seedBlock block from a prior tier (starts loop pre-seeded)
|
|
727
|
+
* @param {string|null} seedErrors errors from a prior tier (shown on attempt 1)
|
|
728
|
+
* @returns {Promise<{ block: string|null, lastBlock: string|null, lastErrors: string|null }>} generation result
|
|
729
|
+
*/
|
|
730
|
+
async function generateBlockWithLoop(
|
|
731
|
+
basePrompt,
|
|
732
|
+
callFn,
|
|
733
|
+
callOpts,
|
|
734
|
+
header,
|
|
735
|
+
dir,
|
|
736
|
+
testDir,
|
|
737
|
+
label,
|
|
738
|
+
maxAttempts = CLOUD_MAX_ATTEMPTS,
|
|
739
|
+
seedBlock = null,
|
|
740
|
+
seedErrors = null
|
|
741
|
+
) {
|
|
742
|
+
let lastBlock = seedBlock
|
|
743
|
+
let lastErrors = seedErrors
|
|
744
|
+
let staleCount = 0
|
|
745
|
+
let prevErrorSig = null // first 120 chars — stable signature for sameness
|
|
746
|
+
|
|
747
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
748
|
+
const diagnostics = buildRetryDiagnostics(lastErrors, prevErrorSig, staleCount, label)
|
|
749
|
+
staleCount = diagnostics.staleCount
|
|
750
|
+
prevErrorSig = diagnostics.prevErrorSig
|
|
751
|
+
|
|
752
|
+
const prompt =
|
|
753
|
+
lastErrors && lastBlock
|
|
754
|
+
? buildRetryPrompt(basePrompt, lastBlock, lastErrors, attempt, diagnostics.rootCauseHints)
|
|
755
|
+
: basePrompt
|
|
756
|
+
|
|
757
|
+
let resp
|
|
758
|
+
try {
|
|
759
|
+
resp = await callFn(prompt, callOpts)
|
|
760
|
+
} catch (error) {
|
|
761
|
+
const failure = resolveLoopCallFailure(error, attempt, maxAttempts, label)
|
|
762
|
+
if (failure.stop) break
|
|
763
|
+
if (failure.reset) {
|
|
764
|
+
lastBlock = null
|
|
765
|
+
lastErrors = null
|
|
766
|
+
prevErrorSig = null
|
|
767
|
+
staleCount = 0
|
|
768
|
+
} else {
|
|
769
|
+
lastErrors = failure.lastErrors
|
|
770
|
+
}
|
|
771
|
+
continue
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const block = extractCode(resp)
|
|
775
|
+
if (!(await isValidBlock(block))) {
|
|
776
|
+
console.log(` ${label} ✗ invalid block (спроба ${attempt}) → retry`)
|
|
777
|
+
lastBlock = block || lastBlock
|
|
778
|
+
lastErrors =
|
|
779
|
+
'Блок не містить валідного describe() або має синтаксичну помилку — поверни лише describe-блок у ```js … ```'
|
|
780
|
+
continue
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const { passed, errors } = runBlock(header, block, dir, testDir)
|
|
784
|
+
if (passed) {
|
|
785
|
+
if (attempt > 1) console.log(` ${label} ✓ passed (спроба ${attempt}/${maxAttempts})`)
|
|
786
|
+
return { block, lastBlock: null, lastErrors: null }
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
console.log(` ${label} ✗ vitest fail (спроба ${attempt}/${maxAttempts})`)
|
|
790
|
+
lastBlock = block
|
|
791
|
+
lastErrors = errors
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
console.log(` ${label} ⚠ ${maxAttempts} спроб вичерпано`)
|
|
795
|
+
return { block: null, lastBlock, lastErrors }
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ---------------------------------------------------------------------------
|
|
799
|
+
// Per-export generation
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Runs best-effort static analysis without interrupting generation.
|
|
804
|
+
* @param {string} content source snippet
|
|
805
|
+
* @returns {Promise<object|null>} AST analysis result or null
|
|
806
|
+
*/
|
|
807
|
+
async function analyzeSourceModule(content) {
|
|
808
|
+
try {
|
|
809
|
+
return await analyzeModule(content)
|
|
810
|
+
} catch {
|
|
811
|
+
return null
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Collects runtime probe data used to ground per-export prompts.
|
|
817
|
+
* @param {string} absPath absolute source path
|
|
818
|
+
* @param {string[]} exports exported symbol names
|
|
819
|
+
* @param {string} content source snippet
|
|
820
|
+
* @param {object|null} astInfo static AST analysis result
|
|
821
|
+
* @returns {object} runtime probe context
|
|
822
|
+
*/
|
|
823
|
+
function buildProbeContext(absPath, exports, content, astInfo) {
|
|
824
|
+
const envKeys = astInfo?.envReads ?? []
|
|
825
|
+
return {
|
|
826
|
+
probeResults: exports.length ? probeModule(absPath, exports, envKeys) : {},
|
|
827
|
+
fetchProbe: astInfo?.usesFetch ? probeFetchCalls(absPath, exports, envKeys) : {},
|
|
828
|
+
timeProbe: TIME_DEPS_RE.test(content) ? probeTimeVariants(absPath, exports, envKeys) : {},
|
|
829
|
+
helperProbe: astInfo?.internalNames?.length ? probeHelpers(absPath, astInfo.internalNames, envKeys) : {}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Prepares source, static analysis and probes for per-export generation.
|
|
835
|
+
* @param {{file: string, pct: number, reason: string}} fileInfo file coverage info
|
|
836
|
+
* @param {string} dir project root
|
|
837
|
+
* @returns {Promise<object>} per-export generation context
|
|
838
|
+
*/
|
|
839
|
+
async function buildPerExportContext(fileInfo, dir) {
|
|
840
|
+
const { file } = fileInfo
|
|
841
|
+
const absPath = join(dir, file)
|
|
842
|
+
const content = readSourceSnippet(absPath)
|
|
843
|
+
const { testFilePath, importPath } = resolveTestPaths(file)
|
|
844
|
+
const hasSideEffects = content.length > 0 && TOP_LEVEL_CALL_RE.test(content)
|
|
845
|
+
const exports = extractExports(content)
|
|
846
|
+
const exportsWithComplexity = extractExportsWithComplexity(content)
|
|
847
|
+
const testRules = findTestRules(dir)
|
|
848
|
+
const astInfo = await analyzeSourceModule(content)
|
|
849
|
+
|
|
850
|
+
return {
|
|
851
|
+
file,
|
|
852
|
+
testFilePath,
|
|
853
|
+
importPath,
|
|
854
|
+
content,
|
|
855
|
+
hasSideEffects,
|
|
856
|
+
exports,
|
|
857
|
+
exportsWithComplexity,
|
|
858
|
+
testRules,
|
|
859
|
+
astInfo,
|
|
860
|
+
...buildProbeContext(absPath, exports, content, astInfo)
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Generates the shared test header through the cloud model.
|
|
866
|
+
* @param {object} ctx per-export generation context
|
|
867
|
+
* @param {string} dir project root
|
|
868
|
+
* @param {PiCallFn} callTextFn cloud LLM caller
|
|
869
|
+
* @returns {Promise<string|null>} generated header or null
|
|
870
|
+
*/
|
|
871
|
+
async function generateSharedHeader(ctx, dir, callTextFn) {
|
|
872
|
+
const { file, testFilePath, importPath, hasSideEffects, content, exports, testRules, astInfo } = ctx
|
|
873
|
+
try {
|
|
874
|
+
const headerResp = await callTextFn(
|
|
875
|
+
buildHeaderPrompt({ file, testFilePath, importPath, hasSideEffects, content, exports, testRules, astInfo }),
|
|
876
|
+
{ cwd: dir }
|
|
877
|
+
)
|
|
878
|
+
const header = extractCode(headerResp)
|
|
879
|
+
if (header) return header
|
|
880
|
+
console.error(` ✗ cloud не повернув header для ${file}`)
|
|
881
|
+
} catch (error) {
|
|
882
|
+
console.error(` ✗ cloud header error: ${error.message}`)
|
|
883
|
+
}
|
|
884
|
+
return null
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Attempts local generation for simple exports and prepares cloud seed context.
|
|
889
|
+
* @param {object} opts local generation options
|
|
890
|
+
* @returns {Promise<{block: string|null, lastBlock: string|null, lastErrors: string|null}>} local result
|
|
891
|
+
*/
|
|
892
|
+
async function generateLocalBlock(opts) {
|
|
893
|
+
const { exp, blockPrompt, header, dir, testDir, callLocalFn, isSimple } = opts
|
|
894
|
+
if (!isSimple || !callLocalFn) return { block: null, lastBlock: null, lastErrors: null }
|
|
895
|
+
|
|
896
|
+
console.log(` ${exp.name} (${exp.complexity}) → local [max ${LOCAL_MAX_ATTEMPTS}]`)
|
|
897
|
+
const result = await generateBlockWithLoop(
|
|
898
|
+
blockPrompt,
|
|
899
|
+
callLocalFn,
|
|
900
|
+
{},
|
|
901
|
+
header,
|
|
902
|
+
dir,
|
|
903
|
+
testDir,
|
|
904
|
+
`${exp.name} [local]:`,
|
|
905
|
+
LOCAL_MAX_ATTEMPTS
|
|
906
|
+
)
|
|
907
|
+
if (result.block) return { block: result.block, lastBlock: null, lastErrors: null }
|
|
908
|
+
|
|
909
|
+
// Only seed cloud with lastBlock if it's a valid block structure.
|
|
910
|
+
// An invalid block as seed causes cascade invalid blocks in cloud.
|
|
911
|
+
const lastBlock = result.lastBlock && isValidBlock(result.lastBlock) ? result.lastBlock : null
|
|
912
|
+
console.log(` ${exp.name} ✗ local exhausted → cloud (з seed-контекстом)`)
|
|
913
|
+
return { block: null, lastBlock, lastErrors: result.lastErrors }
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Generates a block through the cloud tier, optionally seeded by local output.
|
|
918
|
+
* @param {object} opts cloud generation options
|
|
919
|
+
* @returns {Promise<string|null>} generated describe block or last usable block
|
|
920
|
+
*/
|
|
921
|
+
async function generateCloudBlock(opts) {
|
|
922
|
+
const { exp, blockPrompt, header, dir, testDir, callTextFn, isSimple, seed } = opts
|
|
923
|
+
const tier = isSimple ? 'cloud fallback' : 'cloud'
|
|
924
|
+
console.log(` ${exp.name} (${exp.complexity}) → ${tier} [max ${CLOUD_MAX_ATTEMPTS}]`)
|
|
925
|
+
const result = await generateBlockWithLoop(
|
|
926
|
+
blockPrompt,
|
|
927
|
+
callTextFn,
|
|
928
|
+
{ cwd: dir },
|
|
929
|
+
header,
|
|
930
|
+
dir,
|
|
931
|
+
testDir,
|
|
932
|
+
`${exp.name} [cloud]:`,
|
|
933
|
+
CLOUD_MAX_ATTEMPTS,
|
|
934
|
+
seed.lastBlock,
|
|
935
|
+
seed.lastErrors
|
|
936
|
+
)
|
|
937
|
+
return result.block ?? result.lastBlock
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Generates a single export describe block using local-first tiering when available.
|
|
942
|
+
* @param {object} exp export complexity metadata
|
|
943
|
+
* @param {object} ctx per-export generation context including header
|
|
944
|
+
* @param {string} dir project root
|
|
945
|
+
* @param {string} testDir directory where the real test file lives
|
|
946
|
+
* @param {PiCallFn} callTextFn cloud LLM caller
|
|
947
|
+
* @param {PiCallFn} callLocalFn local LLM caller
|
|
948
|
+
* @returns {Promise<string|null>} generated describe block or null
|
|
949
|
+
*/
|
|
950
|
+
async function generateExportBlock(exp, ctx, dir, testDir, callTextFn, callLocalFn) {
|
|
951
|
+
const isSimple = exp.complexity === 'trivial' || exp.complexity === 'simple'
|
|
952
|
+
const blockPrompt = buildBlockPrompt({ exp, ...ctx })
|
|
953
|
+
const seed = await generateLocalBlock({ exp, blockPrompt, header: ctx.header, dir, testDir, callLocalFn, isSimple })
|
|
954
|
+
if (seed.block) return seed.block
|
|
955
|
+
return generateCloudBlock({ exp, blockPrompt, header: ctx.header, dir, testDir, callTextFn, isSimple, seed })
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Writes the merged generated test file.
|
|
960
|
+
* @param {string} dir project root
|
|
961
|
+
* @param {string} testFilePath generated test file path
|
|
962
|
+
* @param {string} code merged test code
|
|
963
|
+
* @param {string[]} blocks generated describe blocks
|
|
964
|
+
* @returns {string} written test path
|
|
965
|
+
*/
|
|
966
|
+
function writeGeneratedTest(dir, testFilePath, code, blocks) {
|
|
967
|
+
const testPath = join(dir, testFilePath)
|
|
968
|
+
mkdirSync(dirname(testPath), { recursive: true })
|
|
969
|
+
writeFileSync(testPath, code + '\n', 'utf8')
|
|
970
|
+
console.log(` ✓ Записано: ${relative(dir, testPath)} (${blocks.length} блоків)`)
|
|
971
|
+
return testPath
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Generates a test file using per-export tiered routing:
|
|
976
|
+
* - Cloud for shared header
|
|
977
|
+
* - Local → cloud fallback for trivial/simple exports
|
|
978
|
+
* - Cloud directly for complex exports
|
|
979
|
+
* @param {{file: string, pct: number, reason: string}} fileInfo file coverage info
|
|
980
|
+
* @param {string} dir project root
|
|
981
|
+
* @param {PiCallFn} callTextFn cloud LLM caller
|
|
982
|
+
* @param {PiCallFn} callLocalFn local LLM caller
|
|
983
|
+
* @returns {Promise<string|null>} written test path or null
|
|
984
|
+
*/
|
|
985
|
+
async function generatePerExport(fileInfo, dir, callTextFn, callLocalFn) {
|
|
986
|
+
const ctx = await buildPerExportContext(fileInfo, dir)
|
|
987
|
+
|
|
988
|
+
console.log(` header → cloud`)
|
|
989
|
+
const header = await generateSharedHeader(ctx, dir, callTextFn)
|
|
990
|
+
if (!header) return null
|
|
991
|
+
|
|
992
|
+
const blocks = []
|
|
993
|
+
const testDir = dirname(join(dir, ctx.testFilePath))
|
|
994
|
+
const blockCtx = { ...ctx, header }
|
|
995
|
+
for (const exp of ctx.exportsWithComplexity) {
|
|
996
|
+
const block = await generateExportBlock(exp, blockCtx, dir, testDir, callTextFn, callLocalFn)
|
|
997
|
+
if (block) blocks.push(block)
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const code = mergeBlocks(header, blocks)
|
|
1001
|
+
if (!code) {
|
|
1002
|
+
console.error(` ✗ merge failed for ${ctx.file}`)
|
|
1003
|
+
return null
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
return writeGeneratedTest(dir, ctx.testFilePath, code, blocks)
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// ---------------------------------------------------------------------------
|
|
1010
|
+
// Single-file (fallback) generation
|
|
1011
|
+
// ---------------------------------------------------------------------------
|
|
1012
|
+
|
|
33
1013
|
/**
|
|
34
1014
|
* Builds a display-only summary prompt (used in tests).
|
|
35
|
-
* @param {Array<{file: string, pct: number, reason: string}>} files
|
|
36
|
-
* @param {string} dir
|
|
37
|
-
* @returns {string}
|
|
1015
|
+
* @param {Array<{file: string, pct: number, reason: string}>} files files to summarize
|
|
1016
|
+
* @param {string} dir project root
|
|
1017
|
+
* @returns {string} summary prompt text
|
|
38
1018
|
*/
|
|
39
1019
|
export function buildGenTestsPrompt(files, dir) {
|
|
40
1020
|
return files
|
|
41
1021
|
.map(({ file, pct, reason }) => {
|
|
42
1022
|
const absPath = join(dir, file)
|
|
43
|
-
|
|
44
|
-
if (existsSync(absPath)) {
|
|
45
|
-
content = readFileSync(absPath, 'utf8')
|
|
46
|
-
if (content.length > MAX_SRC_BYTES) content = content.slice(0, MAX_SRC_BYTES) + '\n...(truncated)'
|
|
47
|
-
}
|
|
1023
|
+
const content = readSourceSnippet(absPath)
|
|
48
1024
|
return (
|
|
49
1025
|
`### \`${file}\` (покриття: ${pct.toFixed(1)}%)\n` +
|
|
50
1026
|
(reason ? `Причина: ${reason}\n\n` : '') +
|
|
@@ -54,14 +1030,19 @@ export function buildGenTestsPrompt(files, dir) {
|
|
|
54
1030
|
.join('\n\n')
|
|
55
1031
|
}
|
|
56
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* Builds the single-file prompt (fallback when per-export unavailable).
|
|
1035
|
+
* @param {{file: string, pct: number, reason: string}} fileInfo file coverage info
|
|
1036
|
+
* @param {string} dir project root
|
|
1037
|
+
* @returns {string} single-file generation prompt
|
|
1038
|
+
*/
|
|
57
1039
|
function buildSingleFilePrompt(fileInfo, dir) {
|
|
58
|
-
const { file, pct, reason } = fileInfo
|
|
1040
|
+
const { file, pct: _pct, reason } = fileInfo
|
|
59
1041
|
const absPath = join(dir, file)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
1042
|
+
const content = readSourceSnippet(absPath)
|
|
1043
|
+
|
|
1044
|
+
const exports = extractExports(content)
|
|
1045
|
+
const hasSideEffects = content.length > 0 && TOP_LEVEL_CALL_RE.test(content)
|
|
65
1046
|
|
|
66
1047
|
const existingTestFile = testCandidates(file).find(c => existsSync(join(dir, c)))
|
|
67
1048
|
let existingSection = ''
|
|
@@ -70,16 +1051,47 @@ function buildSingleFilePrompt(fileInfo, dir) {
|
|
|
70
1051
|
existingSection = `\n\nІснуючий тест (доповни):\n\`\`\`js\n${tc.slice(0, 3000)}\n\`\`\``
|
|
71
1052
|
}
|
|
72
1053
|
|
|
1054
|
+
const { testFilePath, importPath } = resolveTestPaths(file)
|
|
1055
|
+
|
|
1056
|
+
const exportsLine =
|
|
1057
|
+
exports.length > 0
|
|
1058
|
+
? `Тестуй ЛИШЕ публічний API (exports): ${exports.join(', ')}`
|
|
1059
|
+
: 'Тестуй лише публічні (exported) функції — не приватні деталі реалізації'
|
|
1060
|
+
|
|
1061
|
+
const sideEffectsSection = hasSideEffects
|
|
1062
|
+
? [
|
|
1063
|
+
'',
|
|
1064
|
+
'УВАГА: модуль має side-effect при завантаженні (виклик функції на рівні модуля).',
|
|
1065
|
+
'Встанови env/мок ДО import і використовуй dynamic import:',
|
|
1066
|
+
'```js',
|
|
1067
|
+
'process.env.KEY = "value"',
|
|
1068
|
+
`const { fn } = await import("${importPath}")`,
|
|
1069
|
+
'```'
|
|
1070
|
+
]
|
|
1071
|
+
: []
|
|
1072
|
+
|
|
1073
|
+
const testRules = findTestRules(dir)
|
|
1074
|
+
|
|
73
1075
|
return [
|
|
74
|
-
`Напиши
|
|
1076
|
+
`Напиши unit-тест у файл \`${testFilePath}\` для джерела \`${file}\`.`,
|
|
1077
|
+
`КРИТИЧНО — імпорт source: \`"${importPath}"\` (тест у \`${testFilePath}\`, source у \`${file}\`)`,
|
|
75
1078
|
reason ? `Причина: ${reason}` : '',
|
|
76
1079
|
'',
|
|
77
1080
|
'Правила (СУВОРО):',
|
|
78
|
-
'- Перший рядок: `import { vi, describe, it, expect, beforeEach } from "vitest"`',
|
|
79
|
-
'-
|
|
1081
|
+
'- Перший рядок: `import { vi, describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest"` — включай ЛИШЕ те що реально використовуєш',
|
|
1082
|
+
'- Імітуй залежності: `vi.mock("module", () => ({ fn: vi.fn() }))` + `vi.mocked(fn)`',
|
|
80
1083
|
'- НІКОЛИ `jest.*`, НІКОЛИ `require()`',
|
|
81
|
-
|
|
1084
|
+
`- ${exportsLine}`,
|
|
1085
|
+
'- Файл .mjs = чистий JavaScript, НЕ TypeScript. НІКОЛИ: `as Type`, `: TypeName`, generics',
|
|
1086
|
+
'- Замість `fn as vi.Mock` → `vi.mocked(fn)`',
|
|
1087
|
+
'- `vi.spyOn(process, "env")` НЕ ПРАЦЮЄ — для env: `vi.stubEnv("KEY", "val")` + `afterEach(() => vi.unstubAllEnvs())`',
|
|
1088
|
+
'- `vi.spyOn(Date).mockReturnValue(...)` НЕ ПРАЦЮЄ з `new Date()` — для часу: `vi.useFakeTimers()` + `vi.setSystemTime(new Date(...))` + `afterEach(() => vi.useRealTimers())`',
|
|
1089
|
+
`- Шлях до source файлу відносно тест-файлу: \`${importPath}\` (НЕ \`${file}\`)`,
|
|
1090
|
+
'- `describe()` callback НЕ може бути async — `await` тільки у top-level, `beforeAll(async()=>{})`, або `it(async()=>{})`',
|
|
1091
|
+
'- Для regex/escape функцій: НЕ ВГАДУЙ складний expected рядок. Тестуй один символ за раз де результат очевидний: `expect(esc("*")).toBe("\\\\*")`, `expect(esc("!")).toBe("\\\\!")`',
|
|
82
1092
|
'- Поверни ЛИШЕ код тесту у блоці ```js ... ``` — без пояснень',
|
|
1093
|
+
...sideEffectsSection,
|
|
1094
|
+
...(testRules ? ['', '## Конвенції тестів цього проєкту (.cursor/rules/n-test.mdc):', testRules] : []),
|
|
83
1095
|
'',
|
|
84
1096
|
`Джерело (\`${file}\`):`,
|
|
85
1097
|
'```js',
|
|
@@ -92,52 +1104,93 @@ function buildSingleFilePrompt(fileInfo, dir) {
|
|
|
92
1104
|
}
|
|
93
1105
|
|
|
94
1106
|
/**
|
|
95
|
-
* Generates a test for
|
|
96
|
-
* @param {{file: string, pct: number, reason: string}} fileInfo
|
|
97
|
-
* @param {string} dir
|
|
98
|
-
* @param {
|
|
1107
|
+
* Generates a test file for a single source file using the cloud LLM.
|
|
1108
|
+
* @param {{file: string, pct: number, reason: string}} fileInfo file coverage info
|
|
1109
|
+
* @param {string} dir project root
|
|
1110
|
+
* @param {PiCallFn} callTextFn cloud LLM caller
|
|
99
1111
|
* @returns {Promise<string|null>} written test path or null
|
|
100
1112
|
*/
|
|
101
1113
|
async function generateOneTest(fileInfo, dir, callTextFn) {
|
|
102
1114
|
const prompt = buildSingleFilePrompt(fileInfo, dir)
|
|
103
|
-
|
|
104
1115
|
let response
|
|
105
1116
|
try {
|
|
106
1117
|
response = await callTextFn(prompt, { cwd: dir })
|
|
107
|
-
} catch (
|
|
108
|
-
console.error(` ✗ pi помилка для ${fileInfo.file}: ${
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
console.error(` ✗ pi помилка для ${fileInfo.file}: ${error.message}`)
|
|
109
1120
|
return null
|
|
110
1121
|
}
|
|
111
|
-
|
|
112
1122
|
const code = extractCode(response)
|
|
113
1123
|
if (!code) {
|
|
114
1124
|
console.error(` ✗ pi не повернула код для ${fileInfo.file}`)
|
|
115
1125
|
return null
|
|
116
1126
|
}
|
|
117
|
-
|
|
118
1127
|
const testPath = join(dir, testCandidates(fileInfo.file)[0])
|
|
1128
|
+
mkdirSync(dirname(testPath), { recursive: true })
|
|
119
1129
|
writeFileSync(testPath, code + '\n', 'utf8')
|
|
120
1130
|
console.log(` ✓ Записано: ${relative(dir, testPath)}`)
|
|
121
1131
|
return testPath
|
|
122
1132
|
}
|
|
123
1133
|
|
|
1134
|
+
// ---------------------------------------------------------------------------
|
|
1135
|
+
// Public API
|
|
1136
|
+
// ---------------------------------------------------------------------------
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Resolves the effective local model id.
|
|
1140
|
+
* @param {GenerateTestsOptions} opts generation options
|
|
1141
|
+
* @returns {string | null} local model id or null for cloud-only mode
|
|
1142
|
+
*/
|
|
1143
|
+
function resolveLocalModel(opts) {
|
|
1144
|
+
if (opts.localModel !== undefined) return opts.localModel
|
|
1145
|
+
return checkEnv(['N_LOCAL_MIN_MODEL']) ?? null
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Handles one file inside the outer generation loop.
|
|
1150
|
+
* @param {{file: string, pct: number, reason: string}} fileInfo file coverage info
|
|
1151
|
+
* @param {string} dir project root
|
|
1152
|
+
* @param {PiCallFn} callTextFn cloud LLM caller
|
|
1153
|
+
* @param {PiCallFn | null} localFn local LLM caller
|
|
1154
|
+
* @param {GenerateOneFn | undefined} generateOne custom single-file generator
|
|
1155
|
+
* @returns {Promise<void>} resolves after generation for this file completes
|
|
1156
|
+
*/
|
|
1157
|
+
async function generateTestsForFile(fileInfo, dir, callTextFn, localFn, generateOne) {
|
|
1158
|
+
console.log(` → ${fileInfo.file} (${fileInfo.pct.toFixed(1)}%)`)
|
|
1159
|
+
|
|
1160
|
+
if (generateOne) {
|
|
1161
|
+
await generateOne(fileInfo, dir)
|
|
1162
|
+
return
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const exportsInfo = extractExportsWithComplexity(readSourceSnippet(join(dir, fileInfo.file)))
|
|
1166
|
+
if (localFn && exportsInfo.length > 0) {
|
|
1167
|
+
await generatePerExport(fileInfo, dir, callTextFn, localFn)
|
|
1168
|
+
return
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
await generateOneTest(fileInfo, dir, callTextFn)
|
|
1172
|
+
}
|
|
1173
|
+
|
|
124
1174
|
/**
|
|
125
|
-
* Generates tests for all given files
|
|
126
|
-
*
|
|
1175
|
+
* Generates tests for all given files.
|
|
1176
|
+
* Uses per-export tiered routing when local LLM is available;
|
|
1177
|
+
* falls back to single-file cloud generation otherwise.
|
|
1178
|
+
* @param {Array<{file: string, pct: number, reason: string}>} files files to generate tests for
|
|
127
1179
|
* @param {string} dir project root
|
|
128
|
-
* @param {
|
|
129
|
-
* @returns {Promise<void>}
|
|
1180
|
+
* @param {GenerateTestsOptions} [opts] generation options
|
|
1181
|
+
* @returns {Promise<void>} resolves after all requested files are processed
|
|
130
1182
|
*/
|
|
131
1183
|
export async function generateTests(files, dir, opts = {}) {
|
|
132
1184
|
if (files.length === 0) return
|
|
133
1185
|
|
|
134
1186
|
const callTextFn = opts.callText ?? callText
|
|
135
|
-
const
|
|
1187
|
+
const localModel = resolveLocalModel(opts)
|
|
1188
|
+
const localFn = localModel ? prompt => callTextFn(prompt, { model: localModel, cwd: dir }) : null
|
|
136
1189
|
|
|
137
|
-
|
|
1190
|
+
const mode = localFn ? `per-export (local:${localModel} + cloud)` : 'single-file (cloud)'
|
|
1191
|
+
console.log(`\n🤖 Генерую тести для ${files.length} файлів [${mode}]...\n`)
|
|
138
1192
|
|
|
139
1193
|
for (const fileInfo of files) {
|
|
140
|
-
|
|
141
|
-
await generateOneFn(fileInfo, dir)
|
|
1194
|
+
await generateTestsForFile(fileInfo, dir, callTextFn, localFn, opts.generateOne)
|
|
142
1195
|
}
|
|
143
1196
|
}
|