canary-test-cli 5.15.0 → 6.0.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/agent/frameworks/registry.json +655 -0
- package/bin/canary.js +20 -15
- package/dist/doctor-manifest.d.ts +94 -0
- package/dist/doctor.d.ts +67 -0
- package/dist/engine/analysis/cli.js +270 -0
- package/dist/engine/analysis/engine.js +146 -0
- package/dist/engine/analysis/reports.js +0 -0
- package/dist/engine/analysis/rows.js +9 -0
- package/dist/engine/cli-commands.js +618 -0
- package/dist/engine/cli-common.js +60 -0
- package/dist/engine/cli.core.js +208 -0
- package/dist/engine/cli.js +31 -0
- package/dist/engine/company-knowledge-cli.js +201 -0
- package/dist/engine/core/ci-env.js +33 -0
- package/dist/engine/core/classifier.js +192 -0
- package/dist/engine/core/company-knowledge.js +765 -0
- package/dist/engine/core/config-validation.js +74 -0
- package/dist/engine/core/detection.js +48 -0
- package/dist/engine/core/domain-scanner.js +212 -0
- package/dist/engine/core/environment-detect.js +410 -0
- package/dist/engine/core/executor.js +181 -0
- package/dist/engine/core/feedback.js +93 -0
- package/dist/engine/core/fixture-scanner.js +173 -0
- package/dist/engine/core/framework-registry.js +123 -0
- package/dist/engine/core/mcp-validator.js +218 -0
- package/dist/engine/core/metadata-scanner.js +147 -0
- package/dist/engine/core/migrator.js +1112 -0
- package/dist/engine/core/overlays.js +176 -0
- package/dist/engine/core/pattern-healer.js +147 -0
- package/dist/engine/core/pattern-matcher.js +255 -0
- package/dist/engine/core/quality-scorer.js +213 -0
- package/dist/engine/core/recommender.js +152 -0
- package/dist/engine/core/reporter.js +211 -0
- package/dist/engine/core/scaffolder.js +236 -0
- package/dist/engine/core/skill-registry.js +522 -0
- package/dist/engine/core/static-linter.js +237 -0
- package/dist/engine/core/ticket-updater.js +639 -0
- package/dist/engine/core/workflow-discovery.js +693 -0
- package/dist/engine/guardian/agent-tier.js +338 -0
- package/dist/engine/guardian/analysis-emit.js +201 -0
- package/dist/engine/guardian/cli.js +787 -0
- package/dist/engine/guardian/coverage.js +1055 -0
- package/dist/engine/guardian/delta-emitter.js +46 -0
- package/dist/engine/guardian/diff-extractor.js +257 -0
- package/dist/engine/guardian/hard-gate.js +373 -0
- package/dist/engine/guardian/impact-mapper.js +121 -0
- package/dist/engine/guardian/pr-check.js +975 -0
- package/dist/engine/guardian/pr-comment.js +200 -0
- package/dist/engine/guardian/summary-emitter.js +94 -0
- package/dist/engine/guardian/tier.js +58 -0
- package/dist/engine/history/cli.js +303 -0
- package/dist/engine/history/detector.js +68 -0
- package/dist/engine/history/ndjson-store.js +177 -0
- package/dist/engine/history/record.js +14 -0
- package/dist/engine/history/schema.js +59 -0
- package/dist/engine/history/store.js +47 -0
- package/dist/engine/history/supabase-store.js +113 -0
- package/dist/engine/main-deps.js +105 -0
- package/dist/engine/mcp-server.js +647 -0
- package/dist/engine/package.json +4 -0
- package/dist/engine/skills-cli.js +181 -0
- package/dist/engine/ui/banner.js +50 -0
- package/dist/engine/util/coalesce.js +12 -0
- package/dist/engine/util/round.js +43 -0
- package/dist/engine/workflow-cli.js +242 -0
- package/dist/engine-checks.d.ts +49 -0
- package/dist/overlay-commands.d.ts +81 -0
- package/dist/overlay-conflicts.d.ts +33 -0
- package/dist/overlay-lint.d.ts +19 -0
- package/dist/overlays-registry.d.ts +74 -0
- package/dist/reporters/testtracker.d.ts +89 -0
- package/dist/reporters/testtracker.js +195 -0
- package/dist/router.d.ts +12 -0
- package/dist/router.js +4 -4
- package/dist/skill-requirements.d.ts +57 -0
- package/dist/source-spec.d.ts +20 -0
- package/package.json +30 -6
- package/bin/canary +0 -0
- package/scripts/install.js +0 -104
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-aware environment & persona detection (issue #341).
|
|
3
|
+
*
|
|
4
|
+
* Faithful TypeScript port of `agent/core/environment_detect.py`.
|
|
5
|
+
*
|
|
6
|
+
* Canary tailors which skills load, which user pool it queries, and how it
|
|
7
|
+
* phrases output based on *who* is driving and *what* they are testing. This
|
|
8
|
+
* module gathers that context from cheap, local, deterministic signals so a
|
|
9
|
+
* consumer (currently the MCP `analyze_file` path) can attach it to its
|
|
10
|
+
* response without any network call or model round-trip.
|
|
11
|
+
*
|
|
12
|
+
* Three detection paths are implemented — the concrete, testable ones:
|
|
13
|
+
* 1. **BASE_URL** — from `.env` (canonical `BASE_URL` plus common aliases),
|
|
14
|
+
* falling back to a literal `baseURL` in `playwright.config.*`.
|
|
15
|
+
* 2. **Suite hints** — parse `playwright.config.*` for `testDir` / project
|
|
16
|
+
* names / `testMatch` to classify the suite as e2e / component / api.
|
|
17
|
+
* 3. **User level (SDET vs manual)** — a transparent, documented heuristic
|
|
18
|
+
* over cwd and the caller-supplied list of open files.
|
|
19
|
+
*
|
|
20
|
+
* Python→TS nuances:
|
|
21
|
+
* - `os.environ` is not touched here; all inputs are paths.
|
|
22
|
+
* - `Path(p).resolve()` → `resolve(p)`; `Path.exists()` → `existsSync`;
|
|
23
|
+
* `read_text(encoding="utf-8", errors="ignore")` → `readFileSync(p,
|
|
24
|
+
* 'utf-8')` (Node substitutes U+FFFD on bad bytes rather than dropping).
|
|
25
|
+
* - `re.search`/`re.findall` map to a single `.exec` / a global-regex loop.
|
|
26
|
+
* - `round(x, 3)` is Python round-half-to-even, reproduced by {@link round3}.
|
|
27
|
+
* - `Path(f).suffix` → `extname` (both yield "" for a dotfile like `.env`).
|
|
28
|
+
*/
|
|
29
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
30
|
+
import { extname, join, resolve } from 'node:path';
|
|
31
|
+
// Ordered by trust: the canonical name wins over framework-specific aliases.
|
|
32
|
+
const BASE_URL_KEYS = [
|
|
33
|
+
'BASE_URL',
|
|
34
|
+
'PLAYWRIGHT_BASE_URL',
|
|
35
|
+
'E2E_BASE_URL',
|
|
36
|
+
'APP_URL',
|
|
37
|
+
];
|
|
38
|
+
const PLAYWRIGHT_CONFIGS = [
|
|
39
|
+
'playwright.config.ts',
|
|
40
|
+
'playwright.config.js',
|
|
41
|
+
'playwright.config.mts',
|
|
42
|
+
'playwright.config.mjs',
|
|
43
|
+
'playwright.config.cts',
|
|
44
|
+
'playwright.config.cjs',
|
|
45
|
+
];
|
|
46
|
+
// Extensions that indicate someone is writing/reading code or automated tests.
|
|
47
|
+
const CODE_SUFFIXES = new Set([
|
|
48
|
+
'.ts',
|
|
49
|
+
'.tsx',
|
|
50
|
+
'.js',
|
|
51
|
+
'.jsx',
|
|
52
|
+
'.mjs',
|
|
53
|
+
'.cjs',
|
|
54
|
+
'.py',
|
|
55
|
+
'.java',
|
|
56
|
+
'.rb',
|
|
57
|
+
'.go',
|
|
58
|
+
'.cs',
|
|
59
|
+
]);
|
|
60
|
+
// Extensions that indicate manual test artefacts (plans, case sheets, notes).
|
|
61
|
+
const MANUAL_SUFFIXES = new Set([
|
|
62
|
+
'.md',
|
|
63
|
+
'.csv',
|
|
64
|
+
'.xlsx',
|
|
65
|
+
'.xls',
|
|
66
|
+
'.docx',
|
|
67
|
+
'.doc',
|
|
68
|
+
'.pdf',
|
|
69
|
+
'.txt',
|
|
70
|
+
]);
|
|
71
|
+
// Config filenames whose mere presence marks an automation-savvy project.
|
|
72
|
+
const TEST_CONFIG_FILES = [
|
|
73
|
+
'playwright.config.ts',
|
|
74
|
+
'playwright.config.js',
|
|
75
|
+
'vitest.config.ts',
|
|
76
|
+
'vitest.config.js',
|
|
77
|
+
'pytest.ini',
|
|
78
|
+
'jest.config.js',
|
|
79
|
+
'jest.config.ts',
|
|
80
|
+
'cypress.config.ts',
|
|
81
|
+
'cypress.config.js',
|
|
82
|
+
];
|
|
83
|
+
const PROJECT_MANIFESTS = [
|
|
84
|
+
'package.json',
|
|
85
|
+
'pyproject.toml',
|
|
86
|
+
'requirements.txt',
|
|
87
|
+
];
|
|
88
|
+
// cwd path fragments that lean manual.
|
|
89
|
+
const MANUAL_DIR_HINTS = [
|
|
90
|
+
'manual',
|
|
91
|
+
'test-case',
|
|
92
|
+
'testcase',
|
|
93
|
+
'test-plan',
|
|
94
|
+
'testplan',
|
|
95
|
+
];
|
|
96
|
+
/** Python-compatible `round(x, 3)` — round-half-to-even at 3 decimal places. */
|
|
97
|
+
function round3(x) {
|
|
98
|
+
const scaled = x * 1000;
|
|
99
|
+
const floor = Math.floor(scaled);
|
|
100
|
+
const diff = scaled - floor;
|
|
101
|
+
const eps = 1e-9;
|
|
102
|
+
let rounded;
|
|
103
|
+
if (Math.abs(diff - 0.5) < eps) {
|
|
104
|
+
rounded = floor % 2 === 0 ? floor : floor + 1;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
rounded = Math.round(scaled);
|
|
108
|
+
}
|
|
109
|
+
return rounded / 1000;
|
|
110
|
+
}
|
|
111
|
+
/** Split like Python's `str.splitlines()` for the common line endings. */
|
|
112
|
+
function splitLines(text) {
|
|
113
|
+
return text.split(/\r\n|\r|\n/);
|
|
114
|
+
}
|
|
115
|
+
/** Python `str.strip()` (no args) — trim ASCII whitespace both ends. */
|
|
116
|
+
function stripWs(s) {
|
|
117
|
+
return s.trim();
|
|
118
|
+
}
|
|
119
|
+
/** Python `str.strip(ch)` — remove all leading/trailing runs of `ch`. */
|
|
120
|
+
function stripChar(s, ch) {
|
|
121
|
+
let start = 0;
|
|
122
|
+
let end = s.length;
|
|
123
|
+
while (start < end && s[start] === ch)
|
|
124
|
+
start++;
|
|
125
|
+
while (end > start && s[end - 1] === ch)
|
|
126
|
+
end--;
|
|
127
|
+
return s.slice(start, end);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Detected environment/persona context for the current invocation.
|
|
131
|
+
*
|
|
132
|
+
* Python: `EnvironmentContext` (a dataclass). All fields are
|
|
133
|
+
* optional/degradable: absent signals leave sensible `null` / `"unknown"`
|
|
134
|
+
* defaults rather than raising.
|
|
135
|
+
*/
|
|
136
|
+
export class EnvironmentContext {
|
|
137
|
+
base_url;
|
|
138
|
+
base_url_source; // ".env" | "playwright.config" | null
|
|
139
|
+
suite_type; // "e2e" | "component" | "api" | null
|
|
140
|
+
suite_hints;
|
|
141
|
+
user_level; // "sdet" | "manual" | "unknown"
|
|
142
|
+
user_level_signals;
|
|
143
|
+
user_level_confidence;
|
|
144
|
+
constructor(init = {}) {
|
|
145
|
+
this.base_url = init.base_url ?? null;
|
|
146
|
+
this.base_url_source = init.base_url_source ?? null;
|
|
147
|
+
this.suite_type = init.suite_type ?? null;
|
|
148
|
+
this.suite_hints = init.suite_hints ?? [];
|
|
149
|
+
this.user_level = init.user_level ?? 'unknown';
|
|
150
|
+
this.user_level_signals = init.user_level_signals ?? [];
|
|
151
|
+
this.user_level_confidence = init.user_level_confidence ?? 0.0;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Return a JSON-serialisable view for embedding in MCP responses.
|
|
155
|
+
*
|
|
156
|
+
* Python: `EnvironmentContext.to_dict`.
|
|
157
|
+
*/
|
|
158
|
+
toDict() {
|
|
159
|
+
return {
|
|
160
|
+
base_url: this.base_url,
|
|
161
|
+
base_url_source: this.base_url_source,
|
|
162
|
+
suite_type: this.suite_type,
|
|
163
|
+
suite_hints: [...this.suite_hints],
|
|
164
|
+
user_level: this.user_level,
|
|
165
|
+
user_level_signals: [...this.user_level_signals],
|
|
166
|
+
user_level_confidence: round3(this.user_level_confidence),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Path 2 (issue path b): BASE_URL from .env / playwright.config.*
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
/**
|
|
174
|
+
* Return the first BASE_URL-family value from `.env`, or null.
|
|
175
|
+
*
|
|
176
|
+
* Python: `_parse_dotenv_base_url`.
|
|
177
|
+
*/
|
|
178
|
+
function parseDotenvBaseUrl(root) {
|
|
179
|
+
const path = join(root, '.env');
|
|
180
|
+
if (!existsSync(path))
|
|
181
|
+
return null;
|
|
182
|
+
let text;
|
|
183
|
+
try {
|
|
184
|
+
text = readFileSync(path, 'utf-8');
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// Python catches OSError → None.
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const found = {};
|
|
191
|
+
for (const raw of splitLines(text)) {
|
|
192
|
+
let line = stripWs(raw);
|
|
193
|
+
if (!line || line.startsWith('#'))
|
|
194
|
+
continue;
|
|
195
|
+
if (line.startsWith('export ')) {
|
|
196
|
+
line = line.slice('export '.length).replace(/^\s+/, '');
|
|
197
|
+
}
|
|
198
|
+
if (!line.includes('='))
|
|
199
|
+
continue;
|
|
200
|
+
const idx = line.indexOf('=');
|
|
201
|
+
const key = stripWs(line.slice(0, idx));
|
|
202
|
+
let value = stripWs(line.slice(idx + 1));
|
|
203
|
+
value = stripChar(stripChar(value, '"'), "'");
|
|
204
|
+
if (BASE_URL_KEYS.includes(key) &&
|
|
205
|
+
value &&
|
|
206
|
+
!Object.prototype.hasOwnProperty.call(found, key)) {
|
|
207
|
+
found[key] = value;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Honour trust order regardless of file order.
|
|
211
|
+
for (const key of BASE_URL_KEYS) {
|
|
212
|
+
if (Object.prototype.hasOwnProperty.call(found, key))
|
|
213
|
+
return found[key];
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Return a *literal* `baseURL` from playwright.config.*, or null.
|
|
219
|
+
*
|
|
220
|
+
* A `process.env.*` indirection is deliberately ignored — there is no literal
|
|
221
|
+
* URL to report, and inventing one would be misleading.
|
|
222
|
+
*
|
|
223
|
+
* Python: `_parse_config_base_url`.
|
|
224
|
+
*/
|
|
225
|
+
function parseConfigBaseUrl(root) {
|
|
226
|
+
for (const name of PLAYWRIGHT_CONFIGS) {
|
|
227
|
+
const path = join(root, name);
|
|
228
|
+
if (!existsSync(path))
|
|
229
|
+
continue;
|
|
230
|
+
let text;
|
|
231
|
+
try {
|
|
232
|
+
text = readFileSync(path, 'utf-8');
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// Python catches OSError → continue.
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const match = /baseURL\s*:\s*['"]([^'"]+)['"]/.exec(text);
|
|
239
|
+
if (match)
|
|
240
|
+
return match[1];
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Detect the target BASE_URL for the project.
|
|
246
|
+
*
|
|
247
|
+
* Precedence: an explicit `.env` value beats a literal in the Playwright
|
|
248
|
+
* config. Returns `[url, source]` where source is `".env"`,
|
|
249
|
+
* `"playwright.config"`, or `null` when nothing was found.
|
|
250
|
+
*
|
|
251
|
+
* Python: `detect_base_url`.
|
|
252
|
+
*/
|
|
253
|
+
export function detectBaseUrl(projectRoot = '.') {
|
|
254
|
+
const root = resolve(projectRoot);
|
|
255
|
+
const envUrl = parseDotenvBaseUrl(root);
|
|
256
|
+
if (envUrl)
|
|
257
|
+
return [envUrl, '.env'];
|
|
258
|
+
const cfgUrl = parseConfigBaseUrl(root);
|
|
259
|
+
if (cfgUrl)
|
|
260
|
+
return [cfgUrl, 'playwright.config'];
|
|
261
|
+
return [null, null];
|
|
262
|
+
}
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Path 2 (issue path b): suite hints from playwright.config.*
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
/**
|
|
267
|
+
* Infer suite type and collect free-form hints from a Playwright config.
|
|
268
|
+
*
|
|
269
|
+
* Reads `testDir`, `testMatch`, and `projects[].name` and classifies the suite
|
|
270
|
+
* as `"component"`, `"api"`, or `"e2e"`. Playwright is end-to-end by default,
|
|
271
|
+
* so a config present but giving no stronger signal is classified `"e2e"`.
|
|
272
|
+
* Returns `[null, []]` when no Playwright config exists.
|
|
273
|
+
*
|
|
274
|
+
* Python: `parse_playwright_suite_hints`.
|
|
275
|
+
*/
|
|
276
|
+
export function parsePlaywrightSuiteHints(projectRoot = '.') {
|
|
277
|
+
const root = resolve(projectRoot);
|
|
278
|
+
let configText = null;
|
|
279
|
+
for (const name of PLAYWRIGHT_CONFIGS) {
|
|
280
|
+
const path = join(root, name);
|
|
281
|
+
if (existsSync(path)) {
|
|
282
|
+
try {
|
|
283
|
+
configText = readFileSync(path, 'utf-8');
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
configText = '';
|
|
287
|
+
}
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (configText === null)
|
|
292
|
+
return [null, []];
|
|
293
|
+
const hints = [];
|
|
294
|
+
const testDirMatch = /testDir\s*:\s*['"]([^'"]+)['"]/.exec(configText);
|
|
295
|
+
if (testDirMatch)
|
|
296
|
+
hints.push(testDirMatch[1]);
|
|
297
|
+
const testMatch = /testMatch\s*:\s*['"]([^'"]+)['"]/.exec(configText);
|
|
298
|
+
if (testMatch)
|
|
299
|
+
hints.push(testMatch[1]);
|
|
300
|
+
const nameRe = /name\s*:\s*['"]([^'"]+)['"]/g;
|
|
301
|
+
for (let m = nameRe.exec(configText); m !== null; m = nameRe.exec(configText)) {
|
|
302
|
+
hints.push(m[1]);
|
|
303
|
+
}
|
|
304
|
+
const blob = hints.join(' ').toLowerCase();
|
|
305
|
+
let suiteType;
|
|
306
|
+
if (blob.includes('component') ||
|
|
307
|
+
blob.includes('.ct.') ||
|
|
308
|
+
blob.includes('/ct')) {
|
|
309
|
+
suiteType = 'component';
|
|
310
|
+
}
|
|
311
|
+
else if (blob.includes('api')) {
|
|
312
|
+
suiteType = 'api';
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
// Config present but no stronger cue: Playwright defaults to e2e.
|
|
316
|
+
suiteType = 'e2e';
|
|
317
|
+
}
|
|
318
|
+
return [suiteType, hints];
|
|
319
|
+
}
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// Path 3 (issue path c): SDET-vs-manual user-level heuristic
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
/**
|
|
324
|
+
* Classify the driver as an SDET or a manual tester — transparently.
|
|
325
|
+
*
|
|
326
|
+
* An intentionally simple, auditable scoring heuristic: each fired signal adds
|
|
327
|
+
* one point to the SDET or manual tally and is returned so a consumer can see
|
|
328
|
+
* exactly why the verdict came out. Returns `[level, signals, confidence]`
|
|
329
|
+
* where `level` is `"sdet"` / `"manual"` / `"unknown"` (a tie or no signal),
|
|
330
|
+
* and `confidence` is `|sdet - manual| / (sdet + manual)` in `[0, 1]` (`0.0`
|
|
331
|
+
* when no signal fired).
|
|
332
|
+
*
|
|
333
|
+
* Python: `detect_user_level`.
|
|
334
|
+
*/
|
|
335
|
+
export function detectUserLevel(cwd, openFiles = null) {
|
|
336
|
+
const signals = [];
|
|
337
|
+
let sdet = 0;
|
|
338
|
+
let manual = 0;
|
|
339
|
+
const files = openFiles ?? [];
|
|
340
|
+
for (const f of files) {
|
|
341
|
+
const suffix = extname(f).toLowerCase();
|
|
342
|
+
if (CODE_SUFFIXES.has(suffix)) {
|
|
343
|
+
sdet += 1;
|
|
344
|
+
signals.push(`code/test file open: ${f}`);
|
|
345
|
+
}
|
|
346
|
+
else if (MANUAL_SUFFIXES.has(suffix)) {
|
|
347
|
+
manual += 1;
|
|
348
|
+
signals.push(`manual artefact open: ${f}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Python: `cwd_path = Path(cwd)`. `Path("")` normalizes to `"."`, so an empty
|
|
352
|
+
// cwd scans the current directory and its `str()` is `"."` — reproduce that
|
|
353
|
+
// rather than treating `""` as a non-existent path.
|
|
354
|
+
const cwdPath = cwd === '' ? '.' : cwd;
|
|
355
|
+
const cwdLower = cwdPath.toLowerCase();
|
|
356
|
+
if (MANUAL_DIR_HINTS.some((hint) => cwdLower.includes(hint))) {
|
|
357
|
+
manual += 1;
|
|
358
|
+
signals.push('cwd path suggests manual testing');
|
|
359
|
+
}
|
|
360
|
+
if (existsSync(cwdPath)) {
|
|
361
|
+
for (const cfg of TEST_CONFIG_FILES) {
|
|
362
|
+
if (existsSync(join(cwdPath, cfg))) {
|
|
363
|
+
sdet += 1;
|
|
364
|
+
signals.push(`test-framework config present: ${cfg}`);
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
for (const manifest of PROJECT_MANIFESTS) {
|
|
369
|
+
if (existsSync(join(cwdPath, manifest))) {
|
|
370
|
+
sdet += 1;
|
|
371
|
+
signals.push(`project manifest present: ${manifest}`);
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const total = sdet + manual;
|
|
377
|
+
if (total === 0)
|
|
378
|
+
return ['unknown', [], 0.0];
|
|
379
|
+
const confidence = Math.abs(sdet - manual) / total;
|
|
380
|
+
if (sdet > manual)
|
|
381
|
+
return ['sdet', signals, confidence];
|
|
382
|
+
if (manual > sdet)
|
|
383
|
+
return ['manual', signals, confidence];
|
|
384
|
+
return ['unknown', signals, confidence];
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Run every implemented detection path and return the combined context.
|
|
388
|
+
*
|
|
389
|
+
* Python: `detect_environment`. `cwd` defaults to `project_root` when omitted;
|
|
390
|
+
* `open_files` is the caller-supplied list of open/edited paths (this module
|
|
391
|
+
* never guesses them).
|
|
392
|
+
*/
|
|
393
|
+
export function detectEnvironment(projectRoot = '.', options = {}) {
|
|
394
|
+
const { cwd = null, openFiles = null } = options;
|
|
395
|
+
const [baseUrl, baseUrlSource] = detectBaseUrl(projectRoot);
|
|
396
|
+
const [suiteType, suiteHints] = parsePlaywrightSuiteHints(projectRoot);
|
|
397
|
+
// Python: `cwd or project_root` — an empty-string cwd is falsy and collapses
|
|
398
|
+
// to project_root. `??` would wrongly keep `""`, so use `||`.
|
|
399
|
+
const [level, levelSignals, levelConf] = detectUserLevel(cwd || projectRoot, openFiles);
|
|
400
|
+
return new EnvironmentContext({
|
|
401
|
+
base_url: baseUrl,
|
|
402
|
+
base_url_source: baseUrlSource,
|
|
403
|
+
suite_type: suiteType,
|
|
404
|
+
suite_hints: suiteHints,
|
|
405
|
+
user_level: level,
|
|
406
|
+
user_level_signals: levelSignals,
|
|
407
|
+
user_level_confidence: levelConf,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
//# sourceMappingURL=environment-detect.js.map
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canary Test Executor — a framework-agnostic execution engine.
|
|
3
|
+
*
|
|
4
|
+
* Faithful TypeScript port of `agent/core/executor.py`. Executes generated
|
|
5
|
+
* test files using the CLI command declared for each supported framework.
|
|
6
|
+
*
|
|
7
|
+
* Python→TS nuances:
|
|
8
|
+
* - **subprocess → child_process**: Python's `subprocess.run(cmd, ...)` maps
|
|
9
|
+
* to Node's `spawnSync(cmd[0], cmd.slice(1), ...)`. `cmd[0]` is the program
|
|
10
|
+
* and the rest are argv, so the file path (already a single token after
|
|
11
|
+
* `{file}` substitution) never gets re-split by the shell.
|
|
12
|
+
* - **shlex.split**: reproduced by {@link shlexSplit} (POSIX mode). The
|
|
13
|
+
* template is tokenized *first*, then `{file}` is substituted into each
|
|
14
|
+
* token, so a path containing spaces stays exactly one argv element.
|
|
15
|
+
* - **timeout**: Python's `subprocess.TimeoutExpired` maps to spawnSync's
|
|
16
|
+
* `result.error.code === 'ETIMEDOUT'`; both yield exit code 124 with the
|
|
17
|
+
* same byte-exact stderr message. `timeout` is seconds in the API and is
|
|
18
|
+
* converted to milliseconds for spawnSync.
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { constants as osConstants } from 'node:os';
|
|
22
|
+
import { isCi } from './ci-env.js';
|
|
23
|
+
import { FrameworkRegistry } from './framework-registry.js';
|
|
24
|
+
/**
|
|
25
|
+
* POSIX `shlex.split` equivalent: whitespace-separated tokens with `'…'`
|
|
26
|
+
* (fully literal), `"…"` (literal, but `\"`/`\\` escapes honored), and
|
|
27
|
+
* backslash escaping of the next char outside quotes. Sufficient for the
|
|
28
|
+
* framework execution-command templates, which are simple whitespace-separated
|
|
29
|
+
* strings.
|
|
30
|
+
*
|
|
31
|
+
* Python: `shlex.split`.
|
|
32
|
+
*/
|
|
33
|
+
export function shlexSplit(input) {
|
|
34
|
+
const tokens = [];
|
|
35
|
+
let cur = '';
|
|
36
|
+
let hasToken = false;
|
|
37
|
+
let i = 0;
|
|
38
|
+
const n = input.length;
|
|
39
|
+
// Python's `shlex.whitespace` is exactly these four characters — form-feed
|
|
40
|
+
// (\f) and vertical-tab (\v) are NOT separators, so keep them inside tokens.
|
|
41
|
+
const isWs = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
|
|
42
|
+
while (i < n) {
|
|
43
|
+
const c = input[i];
|
|
44
|
+
if (isWs(c)) {
|
|
45
|
+
if (hasToken) {
|
|
46
|
+
tokens.push(cur);
|
|
47
|
+
cur = '';
|
|
48
|
+
hasToken = false;
|
|
49
|
+
}
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
else if (c === '\\') {
|
|
53
|
+
hasToken = true;
|
|
54
|
+
if (i + 1 < n) {
|
|
55
|
+
cur += input[i + 1];
|
|
56
|
+
i += 2;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
// Python `shlex.split` raises `ValueError: No escaped character` on a
|
|
60
|
+
// trailing backslash; this call sits outside `execute`'s try, so the
|
|
61
|
+
// error propagates to the caller. Mirror that rather than silently
|
|
62
|
+
// dropping the character.
|
|
63
|
+
throw new Error('No escaped character');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else if (c === "'") {
|
|
67
|
+
hasToken = true;
|
|
68
|
+
i++;
|
|
69
|
+
while (i < n && input[i] !== "'") {
|
|
70
|
+
cur += input[i];
|
|
71
|
+
i++;
|
|
72
|
+
}
|
|
73
|
+
i++; // consume the closing quote
|
|
74
|
+
}
|
|
75
|
+
else if (c === '"') {
|
|
76
|
+
hasToken = true;
|
|
77
|
+
i++;
|
|
78
|
+
while (i < n && input[i] !== '"') {
|
|
79
|
+
if (input[i] === '\\' &&
|
|
80
|
+
i + 1 < n &&
|
|
81
|
+
(input[i + 1] === '"' || input[i + 1] === '\\')) {
|
|
82
|
+
cur += input[i + 1];
|
|
83
|
+
i += 2;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
cur += input[i];
|
|
87
|
+
i++;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
i++; // consume the closing quote
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
hasToken = true;
|
|
94
|
+
cur += c;
|
|
95
|
+
i++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (hasToken)
|
|
99
|
+
tokens.push(cur);
|
|
100
|
+
return tokens;
|
|
101
|
+
}
|
|
102
|
+
/** Render a caught value the way Python's `str(e)` renders an exception. */
|
|
103
|
+
function errStr(err) {
|
|
104
|
+
if (err instanceof Error)
|
|
105
|
+
return err.message;
|
|
106
|
+
return String(err);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Handles execution of generated tests in a managed subprocess.
|
|
110
|
+
*
|
|
111
|
+
* Python: `CanaryTestExecutor`. Coordinates with the {@link FrameworkRegistry}
|
|
112
|
+
* to determine the correct execution command for a framework and safely
|
|
113
|
+
* executes the test file.
|
|
114
|
+
*/
|
|
115
|
+
export class CanaryTestExecutor {
|
|
116
|
+
registry;
|
|
117
|
+
constructor(registry = new FrameworkRegistry()) {
|
|
118
|
+
this.registry = registry;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Execute a test file using the specified framework.
|
|
122
|
+
*
|
|
123
|
+
* Python: `CanaryTestExecutor.execute`.
|
|
124
|
+
*
|
|
125
|
+
* @param filePath Path to the test file.
|
|
126
|
+
* @param frameworkName Name of the framework (e.g. 'playwright').
|
|
127
|
+
* @param timeout Maximum execution time in seconds. Defaults to 30.
|
|
128
|
+
* @returns `[exit_code, stdout, stderr]`.
|
|
129
|
+
*/
|
|
130
|
+
execute(filePath, frameworkName, timeout = 30) {
|
|
131
|
+
const framework = this.registry.findByName(frameworkName);
|
|
132
|
+
if (framework === null) {
|
|
133
|
+
throw new Error(`Framework '${frameworkName}' not found in registry.`);
|
|
134
|
+
}
|
|
135
|
+
const cmdTemplate = framework.execution_command;
|
|
136
|
+
if (!cmdTemplate) {
|
|
137
|
+
throw new Error(`No execution command defined for framework '${frameworkName}'.`);
|
|
138
|
+
}
|
|
139
|
+
// Split the template first so the file path stays a single argv element
|
|
140
|
+
// even when it contains spaces or shell metacharacters.
|
|
141
|
+
const cmd = shlexSplit(cmdTemplate).map((tok) => tok.split('{file}').join(String(filePath)));
|
|
142
|
+
if (isCi()) {
|
|
143
|
+
cmd.push(...(framework.ci_flags ?? []));
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const result = spawnSync(cmd[0], cmd.slice(1), {
|
|
147
|
+
encoding: 'utf-8',
|
|
148
|
+
timeout: timeout * 1000,
|
|
149
|
+
// Python's subprocess.run has no output ceiling; Node defaults
|
|
150
|
+
// maxBuffer to 1 MiB and, on overflow, kills the child (SIGTERM) and
|
|
151
|
+
// surfaces ENOBUFS with status=null. Test runners (Playwright, Cypress,
|
|
152
|
+
// verbose reporters) routinely exceed 1 MiB, which would flip a passing
|
|
153
|
+
// run into a spurious failure with empty stdout. Remove the cap.
|
|
154
|
+
maxBuffer: Infinity,
|
|
155
|
+
});
|
|
156
|
+
if (result.error) {
|
|
157
|
+
const err = result.error;
|
|
158
|
+
// spawnSync surfaces a timeout as ETIMEDOUT rather than raising, the
|
|
159
|
+
// analog of Python's subprocess.TimeoutExpired.
|
|
160
|
+
if (err.code === 'ETIMEDOUT') {
|
|
161
|
+
return [
|
|
162
|
+
124,
|
|
163
|
+
result.stdout ?? '',
|
|
164
|
+
`Execution timed out after ${timeout} seconds.`,
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
return [1, '', errStr(err)];
|
|
168
|
+
}
|
|
169
|
+
// A signal death (SIGKILL/SIGSEGV, e.g. OOM) yields status=null; Python's
|
|
170
|
+
// subprocess reports the negative signal number as the return code, so a
|
|
171
|
+
// caller inspecting the exact code can still distinguish an OOM-kill.
|
|
172
|
+
const exitCode = result.status ??
|
|
173
|
+
(result.signal ? -(osConstants.signals[result.signal] ?? 1) : 1);
|
|
174
|
+
return [exitCode, result.stdout ?? '', result.stderr ?? ''];
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
return [1, '', errStr(e)];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=executor.js.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-CLI feedback / issue reporting (#345).
|
|
3
|
+
*
|
|
4
|
+
* Faithful TypeScript port of `agent/core/feedback.py`. `canary feedback` lowers
|
|
5
|
+
* the discoverability barrier that makes feedback evaporate at submission time:
|
|
6
|
+
* instead of hunting for where the tracker lives, a user runs one command and
|
|
7
|
+
* gets a **pre-filled GitHub issue** with non-sensitive context already
|
|
8
|
+
* attached. This module is the pure, testable core — it builds the payload and
|
|
9
|
+
* URL; the CLI command handles I/O, confirmation, and opening a browser.
|
|
10
|
+
*
|
|
11
|
+
* Privacy: context is limited to version / OS / runtime / install method. It
|
|
12
|
+
* never reads environment variables or file contents.
|
|
13
|
+
*
|
|
14
|
+
* Python→TS nuances:
|
|
15
|
+
* - **Context shape is a contract**: the four keys `{version, os, python,
|
|
16
|
+
* install}` (and their order) are preserved. The `python` value is the JS
|
|
17
|
+
* *runtime* version (`process.version`) — the Node analog of Python's
|
|
18
|
+
* `platform.python_version()`; the field name is kept for shape fidelity.
|
|
19
|
+
* - `urlencode(...)` (which uses `quote_plus`, space -> `+`) maps to
|
|
20
|
+
* `URLSearchParams`, which also form-encodes with space -> `+` and preserves
|
|
21
|
+
* insertion order. Exotic-character percent-encoding can differ byte-for-byte
|
|
22
|
+
* from CPython's `quote_plus`, but the value round-trips identically on
|
|
23
|
+
* decode (GitHub decodes it), and no test pins the raw query bytes.
|
|
24
|
+
*/
|
|
25
|
+
import { release, type } from 'node:os';
|
|
26
|
+
/** The public issue tracker (from npm/package.json `repository`). */
|
|
27
|
+
export const TRACKER_URL = 'https://github.com/bop-clocktower/canary';
|
|
28
|
+
export const VALID_CATEGORIES = ['bug', 'ux', 'docs', 'idea'];
|
|
29
|
+
function canaryVersion() {
|
|
30
|
+
// Python reads `importlib.metadata.version("canary-test-ai")`, falling back to
|
|
31
|
+
// "unknown". The TS pilot has no equivalent package-metadata lookup wired in,
|
|
32
|
+
// so we return the same best-effort "unknown" sentinel.
|
|
33
|
+
return 'unknown';
|
|
34
|
+
}
|
|
35
|
+
/** Best-effort install-method label — never fails, never inspects secrets. */
|
|
36
|
+
function installMethod() {
|
|
37
|
+
const exe = (process.execPath || '').toLowerCase();
|
|
38
|
+
if (exe.includes('pipx')) {
|
|
39
|
+
return 'pipx';
|
|
40
|
+
}
|
|
41
|
+
return 'pip/npm';
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Non-sensitive diagnostic context to attach to a report.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately excludes environment variables and file contents — only the
|
|
47
|
+
* coarse runtime facts a maintainer needs to triage a CLI report.
|
|
48
|
+
*/
|
|
49
|
+
export function collectContext() {
|
|
50
|
+
return {
|
|
51
|
+
version: canaryVersion(),
|
|
52
|
+
os: `${type()} ${release()}`.trim(),
|
|
53
|
+
python: process.version,
|
|
54
|
+
install: installMethod(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A pre-filled GitHub 'new issue' URL: category in the title, message + context
|
|
59
|
+
* in the body, category as a label. All parts are URL-encoded.
|
|
60
|
+
*/
|
|
61
|
+
export function buildIssueUrl(category, message, context) {
|
|
62
|
+
// Python `message[:60]` slices by code point; JS `slice` slices by UTF-16
|
|
63
|
+
// unit, so an astral char would truncate the title early. Match the oracle.
|
|
64
|
+
const title = `[${category}] ${Array.from(message).slice(0, 60).join('')}`.trim();
|
|
65
|
+
const bodyLines = [
|
|
66
|
+
message,
|
|
67
|
+
'',
|
|
68
|
+
'---',
|
|
69
|
+
'_Submitted via `canary feedback`._',
|
|
70
|
+
'',
|
|
71
|
+
'**Environment**',
|
|
72
|
+
];
|
|
73
|
+
for (const [k, v] of Object.entries(context)) {
|
|
74
|
+
bodyLines.push(`- ${k}: ${v}`);
|
|
75
|
+
}
|
|
76
|
+
const query = new URLSearchParams({
|
|
77
|
+
title,
|
|
78
|
+
body: bodyLines.join('\n'),
|
|
79
|
+
labels: category,
|
|
80
|
+
});
|
|
81
|
+
return `${TRACKER_URL}/issues/new?${query.toString()}`;
|
|
82
|
+
}
|
|
83
|
+
/** Bundle a report: message, category, context, and the pre-filled URL. */
|
|
84
|
+
export function buildFeedback(message, category) {
|
|
85
|
+
const context = collectContext();
|
|
86
|
+
return {
|
|
87
|
+
message,
|
|
88
|
+
category,
|
|
89
|
+
context,
|
|
90
|
+
issue_url: buildIssueUrl(category, message, context),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=feedback.js.map
|