tewip-ai 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/QUICKSTART.md +178 -0
- package/README.md +308 -0
- package/TEAM-GUIDE.md +234 -0
- package/dist/cli/tewip.js +117 -0
- package/dist/engine/autofix/autofix.js +63 -0
- package/dist/engine/autofix/candidates.js +33 -0
- package/dist/engine/autofix/locate.js +78 -0
- package/dist/engine/corrections.js +66 -0
- package/dist/engine/dataset.js +41 -0
- package/dist/engine/decide.js +130 -0
- package/dist/engine/device-errors.js +45 -0
- package/dist/engine/drift-rules.js +98 -0
- package/dist/engine/driver-errors.js +103 -0
- package/dist/engine/engine.js +76 -0
- package/dist/engine/error-parse.js +150 -0
- package/dist/engine/history.js +105 -0
- package/dist/engine/import/blob.js +165 -0
- package/dist/engine/import/junit.js +235 -0
- package/dist/engine/import/xml.js +104 -0
- package/dist/engine/llm/anthropic.js +59 -0
- package/dist/engine/llm/client.js +139 -0
- package/dist/engine/llm/context.js +141 -0
- package/dist/engine/llm/llm-diagnose.js +48 -0
- package/dist/engine/llm/prompt.js +40 -0
- package/dist/engine/llm/providers.js +54 -0
- package/dist/engine/llm/usage.js +69 -0
- package/dist/engine/pipeline.js +33 -0
- package/dist/engine/quality/frameworks.js +181 -0
- package/dist/engine/quality/grounding.js +203 -0
- package/dist/engine/quality/model.js +17 -0
- package/dist/engine/quality/plan-command.js +34 -0
- package/dist/engine/quality/plan-render.js +38 -0
- package/dist/engine/quality/plan.js +119 -0
- package/dist/engine/quality/write-command.js +146 -0
- package/dist/engine/quality/write-verify.js +140 -0
- package/dist/engine/quality/write.js +144 -0
- package/dist/engine/rules.js +174 -0
- package/dist/engine/signature.js +23 -0
- package/dist/engine/similarity.js +76 -0
- package/dist/engine/snapshot.js +56 -0
- package/dist/engine/timing-rules.js +188 -0
- package/dist/engine/trace/dom.js +92 -0
- package/dist/engine/trace/trace-summary.js +87 -0
- package/dist/engine/trace/zip.js +53 -0
- package/dist/eval/harvest/gh.js +21 -0
- package/dist/eval/harvest/harvest.js +117 -0
- package/dist/mcp/github-report.js +50 -0
- package/dist/mcp/server.js +80 -0
- package/dist/mcp/tools.js +124 -0
- package/dist/outlets/design-debt.js +22 -0
- package/dist/outlets/items.js +94 -0
- package/dist/outlets/view.js +63 -0
- package/dist/outlets/views/architecture.js +77 -0
- package/dist/outlets/views/developer.js +59 -0
- package/dist/outlets/views/digest.js +66 -0
- package/dist/outlets/views/infra.js +49 -0
- package/dist/outlets/views/quality.js +108 -0
- package/dist/outlets/views/release.js +33 -0
- package/dist/outlets/views/sdet.js +29 -0
- package/dist/reporters/run-context.js +20 -0
- package/dist/reporters/store.js +46 -0
- package/dist/reporters/triage-reporter.js +101 -0
- package/dist/reporters/types.js +4 -0
- package/dist/scripts/explain.js +58 -0
- package/package.json +81 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tewip on the command line. One command, a few verbs:
|
|
3
|
+
// tewip explain <report> why each test in a report failed, and what to do next
|
|
4
|
+
// tewip ci <owner/repo> [--pr N] the latest Tewip report from a repository's CI
|
|
5
|
+
// tewip plan "<feature>" a test plan grounded in the project
|
|
6
|
+
// tewip write [--only IDs] tests for a plan's scenarios, kept only when verified
|
|
7
|
+
// tewip mcp serve Tewip to an AI assistant or editor over MCP (stdio)
|
|
8
|
+
// tewip --version | --help
|
|
9
|
+
// The verbs reuse the same code as the GitHub Action and the MCP server: one engine, many outlets.
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
|
+
// cli/ in the source tree, dist/cli/ in the published package: package.json is one or two levels up.
|
|
14
|
+
const ROOT = [join(import.meta.dirname, '..'), join(import.meta.dirname, '..', '..')].find((d) => existsSync(join(d, 'package.json'))) ?? join(import.meta.dirname, '..');
|
|
15
|
+
const version = () => JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
|
|
16
|
+
const HELP = `Tewip - finds why each test failed, for any stack.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
tewip explain <report> Explain a test report: a JUnit XML or .trx file, a folder of
|
|
20
|
+
them, or a Playwright blob-report folder. Keeps history per
|
|
21
|
+
project, so later runs are judged against earlier ones.
|
|
22
|
+
--name <project> Keep this project's history apart from others.
|
|
23
|
+
tewip ci <owner/repo> [--pr N | --branch B]
|
|
24
|
+
Show the latest report Tewip's GitHub Action uploaded (uses
|
|
25
|
+
your gh login). Without --pr or --branch: the newest run.
|
|
26
|
+
tewip plan "<feature>" [--diff main...HEAD] [--url <page> ...] [--out plan.md]
|
|
27
|
+
Write a test plan grounded in the project: its existing tests,
|
|
28
|
+
the change, the live pages and Tewip's failure history.
|
|
29
|
+
tewip write [--plan plan.json] [--only LS-002,LS-003] [--framework playwright] [--runs 3]
|
|
30
|
+
Write tests for the plan's scenarios in the project's own
|
|
31
|
+
framework (Playwright, Cypress, WebdriverIO, Jest, Vitest,
|
|
32
|
+
pytest, JUnit, .NET, Go). Keeps a test only if it passes every
|
|
33
|
+
run and fails when its check is broken on purpose.
|
|
34
|
+
--move-to <folder> Move kept tests there afterwards, leaving the project as it was.
|
|
35
|
+
tewip mcp Serve Tewip over MCP (stdio) for an editor or AI assistant.
|
|
36
|
+
tewip --version Print the version.
|
|
37
|
+
|
|
38
|
+
Reports it reads: Playwright, pytest/unittest, Jest, Vitest, Mocha, node:test, JUnit/TestNG
|
|
39
|
+
(Maven, Gradle), Go (gotestsum), .NET (TRX or JUnit), RSpec, PHPUnit, Rust (nextest).
|
|
40
|
+
Set OPENAI_API_KEY to let the AI layer decide what the rules leave open.`;
|
|
41
|
+
const flag = (args, name) => {
|
|
42
|
+
const i = args.indexOf(`--${name}`);
|
|
43
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
44
|
+
};
|
|
45
|
+
async function main() {
|
|
46
|
+
const [verb, ...args] = process.argv.slice(2);
|
|
47
|
+
switch (verb) {
|
|
48
|
+
case 'explain': {
|
|
49
|
+
// The explain script reads its arguments from process.argv, as when it is run directly.
|
|
50
|
+
process.argv = [process.argv[0], 'tewip-explain', ...args];
|
|
51
|
+
// Answers the AI layer already gave are kept with the project, not inside the installed tool.
|
|
52
|
+
process.env.TEWIP_LLM_CACHE ??= join(process.cwd(), '.tewip', 'llm-cache.jsonl');
|
|
53
|
+
await import('../scripts/explain.js');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
case 'ci': {
|
|
57
|
+
const repo = args.find((a) => /^[\w.-]+\/[\w.-]+$/.test(a));
|
|
58
|
+
if (!repo)
|
|
59
|
+
throw new Error('usage: tewip ci <owner/repo> [--pr N | --branch B]');
|
|
60
|
+
const pr = flag(args, 'pr');
|
|
61
|
+
const { githubCiReport } = await import('../mcp/github-report.js');
|
|
62
|
+
console.log(await githubCiReport(repo, pr ? Number(pr) : undefined, flag(args, 'branch')));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
case 'plan': {
|
|
66
|
+
const feature = args.filter((a, i) => !a.startsWith('--') && !['--diff', '--url', '--out'].includes(args[i - 1] ?? '')).join(' ').trim();
|
|
67
|
+
if (!feature)
|
|
68
|
+
throw new Error('usage: tewip plan "<what the feature does>" [--diff main...HEAD] [--url http://localhost:3000/page ...] [--out plan.md]');
|
|
69
|
+
const urls = args.flatMap((a, i) => (args[i - 1] === '--url' ? [a] : []));
|
|
70
|
+
const { runPlan } = await import('../engine/quality/plan-command.js');
|
|
71
|
+
await runPlan({ feature, root: process.cwd(), diffRange: flag(args, 'diff'), urls, out: flag(args, 'out') });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
case 'write': {
|
|
75
|
+
const number = (name, fallback) => (flag(args, name) === undefined ? fallback : Math.max(0, Number(flag(args, name)) || 0));
|
|
76
|
+
const { runWrite } = await import('../engine/quality/write-command.js');
|
|
77
|
+
await runWrite({
|
|
78
|
+
root: process.cwd(),
|
|
79
|
+
plan: flag(args, 'plan'),
|
|
80
|
+
only: (flag(args, 'only') ?? '').split(',').map((s) => s.trim()).filter(Boolean),
|
|
81
|
+
framework: flag(args, 'framework'),
|
|
82
|
+
urls: args.flatMap((a, i) => (args[i - 1] === '--url' ? [a] : [])),
|
|
83
|
+
runs: Math.max(1, number('runs', 3)),
|
|
84
|
+
repairs: number('repairs', 2),
|
|
85
|
+
max: Math.max(1, number('max', 10)),
|
|
86
|
+
moveTo: flag(args, 'move-to') ? resolve(flag(args, 'move-to')) : undefined,
|
|
87
|
+
});
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
case 'mcp':
|
|
91
|
+
// An editor starts this from any folder, and an installed tool's own folder may be read-only:
|
|
92
|
+
// the AI answer cache goes to the user's home folder, which is always writable.
|
|
93
|
+
process.env.TEWIP_LLM_CACHE ??= join(homedir(), '.tewip', 'llm-cache.jsonl');
|
|
94
|
+
await import('../mcp/server.js');
|
|
95
|
+
return;
|
|
96
|
+
case '--version':
|
|
97
|
+
case '-v':
|
|
98
|
+
console.log(version());
|
|
99
|
+
return;
|
|
100
|
+
case undefined:
|
|
101
|
+
case '--help':
|
|
102
|
+
case '-h':
|
|
103
|
+
case 'help':
|
|
104
|
+
console.log(HELP);
|
|
105
|
+
return;
|
|
106
|
+
default:
|
|
107
|
+
console.error(`tewip: unknown command "${verb}"\n\n${HELP}`);
|
|
108
|
+
process.exitCode = 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
await main();
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
console.error(`tewip: ${error instanceof Error ? error.message : String(error)}`);
|
|
116
|
+
process.exitCode = 1;
|
|
117
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ACT + VERIFY for LOCATOR_DRIFT (ROADMAP Phase 5): rewrite the drifted locator in the test,
|
|
2
|
+
// re-run that single test, keep the change only if it passes, otherwise restore the file.
|
|
3
|
+
// Safety rules enforced here, not left to callers:
|
|
4
|
+
// - only failures the DECIDE step marked as agent-eligible fix-locator actions (> 0.85)
|
|
5
|
+
// - only files inside `allowedRoot` (a sandbox copy of the tests), never application code
|
|
6
|
+
// - never when the test has no assertion after the rewritten call (a pass would prove nothing)
|
|
7
|
+
// - at most MAX_CANDIDATES attempts; every step is logged with its reason
|
|
8
|
+
import { relative, resolve, sep } from 'node:path';
|
|
9
|
+
import { extractLocator } from '../error-parse.js';
|
|
10
|
+
import { candidatesFor, strictCandidates } from './candidates.js';
|
|
11
|
+
import { failingLine, findLocatorSites, hasAssertionAfter } from './locate.js';
|
|
12
|
+
export const MAX_CANDIDATES = 3;
|
|
13
|
+
function pickSite(sites, line) {
|
|
14
|
+
if (sites.length <= 1 || line === null)
|
|
15
|
+
return sites[0];
|
|
16
|
+
// Prefer the call on the failing line, else the nearest one above it (same test body).
|
|
17
|
+
return sites.find((s) => s.line === line) ?? [...sites].filter((s) => s.line <= line).sort((a, b) => b.line - a.line)[0] ?? sites[0];
|
|
18
|
+
}
|
|
19
|
+
export async function attemptFix({ record, diagnosis, action, specFile }, deps, { keep = true } = {}) {
|
|
20
|
+
const title = record.testId.split(' > ').at(-1) ?? record.testId;
|
|
21
|
+
const result = { testId: record.testId, specFile, status: 'not-eligible', applied: false, attempts: [], log: [] };
|
|
22
|
+
const stop = (status, why) => ({ ...result, status, log: [...result.log, why] });
|
|
23
|
+
if (action.kind !== 'fix-locator' || (keep && !action.autonomous)) {
|
|
24
|
+
return stop('not-eligible', `not an agent-eligible locator fix (${diagnosis.category} at ${diagnosis.confidence.toFixed(2)}, action ${action.kind})`);
|
|
25
|
+
}
|
|
26
|
+
const rel = relative(resolve(deps.allowedRoot), resolve(specFile));
|
|
27
|
+
if (rel.startsWith('..') || rel.startsWith(sep) || /^[a-zA-Z]:/.test(rel))
|
|
28
|
+
return stop('outside-sandbox', `refused: ${specFile} is outside the sandbox ${deps.allowedRoot}`);
|
|
29
|
+
const locator = extractLocator(record.errorMessage);
|
|
30
|
+
const source = deps.readFile(specFile);
|
|
31
|
+
const site = locator ? pickSite(findLocatorSites(source, locator), failingLine(record.errorStack, specFile)) : undefined;
|
|
32
|
+
if (!locator || !site)
|
|
33
|
+
return stop('locator-not-found', `could not find the failing locator ${locator ?? '(none in error)'} in ${specFile}`);
|
|
34
|
+
result.original = site.text;
|
|
35
|
+
result.line = site.line;
|
|
36
|
+
result.log.push(`found ${site.text} at line ${site.line}`);
|
|
37
|
+
if (!hasAssertionAfter(source, site.line))
|
|
38
|
+
return stop('no-assertion', 'refused: the test has no expect() after this call, so a passing re-run would not prove the fix');
|
|
39
|
+
const snapshot = deps.loadSnapshot(record) ?? [];
|
|
40
|
+
const candidates = [...strictCandidates(diagnosis), ...candidatesFor(locator, snapshot, MAX_CANDIDATES)].slice(0, MAX_CANDIDATES);
|
|
41
|
+
if (candidates.length === 0)
|
|
42
|
+
return stop('no-candidate', 'no element on the failure-time page is similar enough to the old locator');
|
|
43
|
+
for (const c of candidates) {
|
|
44
|
+
const patched = source.slice(0, site.start) + c.expression + source.slice(site.end);
|
|
45
|
+
deps.writeFile(specFile, patched);
|
|
46
|
+
const outcome = await deps.verify(specFile, title);
|
|
47
|
+
result.attempts.push({ candidate: c.expression, passed: outcome.passed, detail: outcome.detail });
|
|
48
|
+
result.log.push(`tried ${c.expression} (similarity ${c.score.toFixed(2)}): ${outcome.passed ? 'test passed' : `test still fails (${outcome.detail})`}`);
|
|
49
|
+
if (outcome.passed) {
|
|
50
|
+
if (!keep)
|
|
51
|
+
deps.writeFile(specFile, source);
|
|
52
|
+
return { ...result, status: 'fixed', applied: keep, replacement: c.expression, log: keep ? result.log : [...result.log, 'suggestion check only: file restored'] };
|
|
53
|
+
}
|
|
54
|
+
deps.writeFile(specFile, source);
|
|
55
|
+
}
|
|
56
|
+
return stop('all-candidates-failed', `all ${candidates.length} candidate(s) failed verification; original file restored`);
|
|
57
|
+
}
|
|
58
|
+
/** Unified-diff-style summary of an accepted fix, for a PR description. */
|
|
59
|
+
export function fixDiff(fix, displayPath) {
|
|
60
|
+
if (!fix.applied || !fix.original || !fix.replacement)
|
|
61
|
+
return '';
|
|
62
|
+
return [`--- a/${displayPath}`, `+++ b/${displayPath}`, `@@ line ${fix.line} @@`, `-${fix.original}`, `+${fix.replacement}`].join('\n');
|
|
63
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { DRIFT_MIN_SIMILARITY } from '../drift-rules.js';
|
|
2
|
+
import { rankCandidates } from '../similarity.js';
|
|
3
|
+
const ROLE_LOCATABLE = new Set(['button', 'link', 'heading', 'textbox', 'checkbox', 'radio', 'combobox', 'tab', 'menuitem', 'option', 'searchbox', 'switch']);
|
|
4
|
+
const quote = (s) => `'${s.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`;
|
|
5
|
+
export function locatorFor(el) {
|
|
6
|
+
const name = el.name || el.text;
|
|
7
|
+
if (!name)
|
|
8
|
+
return null;
|
|
9
|
+
return ROLE_LOCATABLE.has(el.role) ? `getByRole(${quote(el.role)}, { name: ${quote(name)}, exact: true })` : `getByText(${quote(name)}, { exact: true })`;
|
|
10
|
+
}
|
|
11
|
+
export function candidatesFor(printedLocator, snapshot, max = 3) {
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const { element, score } of rankCandidates(printedLocator, snapshot)) {
|
|
15
|
+
if (score < DRIFT_MIN_SIMILARITY || out.length >= max)
|
|
16
|
+
break;
|
|
17
|
+
const expression = locatorFor(element);
|
|
18
|
+
if (!expression || seen.has(expression))
|
|
19
|
+
continue;
|
|
20
|
+
seen.add(expression);
|
|
21
|
+
out.push({ expression, element, score });
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
/** The unique locator Playwright printed for the intended element of a strict-mode violation. */
|
|
26
|
+
export function strictCandidates(d) {
|
|
27
|
+
return d.evidence.flatMap((e) => {
|
|
28
|
+
const { replacement, candidate } = (e.data ?? {});
|
|
29
|
+
if (typeof replacement !== 'string')
|
|
30
|
+
return [];
|
|
31
|
+
return [{ expression: replacement, element: { role: candidate?.role ?? '', name: candidate?.name ?? '', text: '' }, score: 1 }];
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Finds where a failing locator is written in a spec file, so the auto-fix loop can rewrite
|
|
2
|
+
// exactly that call and nothing else.
|
|
3
|
+
const CALL_START = /\b(?:locator|getBy(?:Role|Text|Label|TestId|Placeholder|Title|AltText))\(/g;
|
|
4
|
+
/** Canonical form for comparing source text with the locator Playwright printed. */
|
|
5
|
+
export function normalizeLocator(s) {
|
|
6
|
+
return s.replaceAll('"', "'").replace(/\s+/g, '').replace(/,([}\)])/g, '$1');
|
|
7
|
+
}
|
|
8
|
+
/** Index just past the ")" that closes the "(" at `open`, skipping strings; -1 if unbalanced. */
|
|
9
|
+
function closeParen(src, open) {
|
|
10
|
+
let depth = 0;
|
|
11
|
+
let quote = null;
|
|
12
|
+
for (let i = open; i < src.length; i++) {
|
|
13
|
+
const c = src[i];
|
|
14
|
+
if (quote) {
|
|
15
|
+
if (c === '\\')
|
|
16
|
+
i++;
|
|
17
|
+
else if (c === quote)
|
|
18
|
+
quote = null;
|
|
19
|
+
}
|
|
20
|
+
else if (c === "'" || c === '"' || c === '`')
|
|
21
|
+
quote = c;
|
|
22
|
+
else if (c === '(')
|
|
23
|
+
depth++;
|
|
24
|
+
else if (c === ')' && --depth === 0)
|
|
25
|
+
return i + 1;
|
|
26
|
+
}
|
|
27
|
+
return -1;
|
|
28
|
+
}
|
|
29
|
+
/** The last call in a printed chain, e.g. "locator('#a').locator('.b')" -> "locator('.b')". */
|
|
30
|
+
export function lastCall(printed) {
|
|
31
|
+
const starts = [...printed.matchAll(CALL_START)].map((m) => m.index);
|
|
32
|
+
if (starts.length === 0)
|
|
33
|
+
return printed;
|
|
34
|
+
const start = starts[starts.length - 1];
|
|
35
|
+
const end = closeParen(printed, printed.indexOf('(', start));
|
|
36
|
+
return end > 0 ? printed.slice(start, end) : printed.slice(start);
|
|
37
|
+
}
|
|
38
|
+
/** Every call in `source` that is the same locator as `printed` (its last call). */
|
|
39
|
+
export function findLocatorSites(source, printed) {
|
|
40
|
+
const want = normalizeLocator(lastCall(printed));
|
|
41
|
+
const sites = [];
|
|
42
|
+
for (const m of source.matchAll(CALL_START)) {
|
|
43
|
+
const start = m.index;
|
|
44
|
+
const end = closeParen(source, start + m[0].length - 1);
|
|
45
|
+
if (end < 0)
|
|
46
|
+
continue;
|
|
47
|
+
const text = source.slice(start, end);
|
|
48
|
+
if (normalizeLocator(text) === want)
|
|
49
|
+
sites.push({ start, end, text, line: source.slice(0, start).split('\n').length });
|
|
50
|
+
}
|
|
51
|
+
return sites;
|
|
52
|
+
}
|
|
53
|
+
/** Line of `specFile` in a stack trace ("at …/shop.spec.ts:12:5"), or null. */
|
|
54
|
+
export function failingLine(stack, specFile) {
|
|
55
|
+
const name = specFile.split(/[\\/]/).pop();
|
|
56
|
+
for (const line of stack.split('\n')) {
|
|
57
|
+
const m = line.match(/[\\/]([^\\/:]+):(\d+):\d+\)?\s*$/);
|
|
58
|
+
if (m && m[1] === name)
|
|
59
|
+
return Number(m[2]);
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* True when the test still checks something after `line`: without an expect() after the
|
|
65
|
+
* rewritten call, a passing re-run would prove nothing about the fix.
|
|
66
|
+
*/
|
|
67
|
+
export function hasAssertionAfter(source, line) {
|
|
68
|
+
const lines = source.split('\n');
|
|
69
|
+
for (let i = line - 1; i < lines.length; i++) {
|
|
70
|
+
if (i > line - 1 && /^\s*test(\.\w+)?\(/.test(lines[i]))
|
|
71
|
+
return false;
|
|
72
|
+
if (/\bexpect\s*\(/.test(lines[i]))
|
|
73
|
+
return true;
|
|
74
|
+
if (/^\}\);?\s*$/.test(lines[i]))
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// LEARN step: what people told the agent it got wrong. A correction is a human's category for
|
|
2
|
+
// one failure, written where the agent posted its answer (a PR comment, an issue comment) as
|
|
3
|
+
// "/triage CATEGORY reason". It is stored with the run history and applied to later failures of
|
|
4
|
+
// the same test that fail the same way, so the agent does not repeat a mistake a person already
|
|
5
|
+
// corrected. Corrections decide the category only: they never authorise a code change on their
|
|
6
|
+
// own, because a person's category is not a verified replacement locator.
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { ROOT_CAUSES } from '../reporters/types.js';
|
|
9
|
+
import { appendJsonl, readJsonl } from '../reporters/store.js';
|
|
10
|
+
import { errorShape, truncate } from './signature.js';
|
|
11
|
+
export const CORRECTIONS_FILE = 'corrections.jsonl';
|
|
12
|
+
const file = (dir) => join(dir, CORRECTIONS_FILE);
|
|
13
|
+
export const loadCorrections = (dir) => readJsonl(file(dir));
|
|
14
|
+
/** Appends corrections that are not already stored (same test, shape, category and author). */
|
|
15
|
+
export function saveCorrections(dir, corrections) {
|
|
16
|
+
const existing = loadCorrections(dir);
|
|
17
|
+
const seen = new Set(existing.map((c) => `${c.testId}|${c.errorShape}|${c.category}|${c.by}`));
|
|
18
|
+
const fresh = corrections.filter((c) => !seen.has(`${c.testId}|${c.errorShape}|${c.category}|${c.by}`));
|
|
19
|
+
if (fresh.length > 0)
|
|
20
|
+
appendJsonl(file(dir), fresh);
|
|
21
|
+
return fresh;
|
|
22
|
+
}
|
|
23
|
+
/** The most recent correction for this test failing this way, if a person made one. */
|
|
24
|
+
export function correctionFor(record, corrections) {
|
|
25
|
+
const shape = errorShape(record.errorMessage);
|
|
26
|
+
const matches = corrections.filter((c) => c.testId === record.testId && c.errorShape === shape);
|
|
27
|
+
return matches.sort((a, b) => a.at.localeCompare(b.at)).at(-1) ?? null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Applies a person's correction to a diagnosis. The category becomes theirs, at full confidence,
|
|
31
|
+
* and the agent's own answer is kept as evidence so the disagreement stays visible.
|
|
32
|
+
*/
|
|
33
|
+
export function applyCorrection(diagnosis, correction) {
|
|
34
|
+
const said = `${diagnosis.category} at ${diagnosis.confidence.toFixed(2)}`;
|
|
35
|
+
return {
|
|
36
|
+
...diagnosis,
|
|
37
|
+
category: correction.category,
|
|
38
|
+
confidence: 1,
|
|
39
|
+
source: 'human',
|
|
40
|
+
evidence: [
|
|
41
|
+
{
|
|
42
|
+
source: 'human',
|
|
43
|
+
message: `@${correction.by} corrected this to ${correction.category}${correction.reason ? `: ${truncate(correction.reason, 160)}` : ''}${correction.source ? ` (${correction.source})` : ''}`,
|
|
44
|
+
data: { correctedAt: correction.at, was: said },
|
|
45
|
+
},
|
|
46
|
+
...diagnosis.evidence,
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const COMMAND = /(?:^|\s)\/triage\s+([A-Z_]+)\b[ \t]*(.*)$/gim;
|
|
51
|
+
/**
|
|
52
|
+
* Reads "/triage CATEGORY reason" commands out of a comment. A comment may correct several
|
|
53
|
+
* failures, each line naming the board number it means: "/triage TIMING #3 flaky in CI".
|
|
54
|
+
*/
|
|
55
|
+
export function parseCorrectionCommands(body) {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const [, word, rest] of body.matchAll(COMMAND)) {
|
|
58
|
+
const category = ROOT_CAUSES.find((c) => c === word.toUpperCase());
|
|
59
|
+
if (!category)
|
|
60
|
+
continue;
|
|
61
|
+
const ref = rest.match(/(?:^|\s)([\w.-]+)?#(\d+)/);
|
|
62
|
+
const reason = (ref ? rest.replace(ref[0], ' ') : rest).trim();
|
|
63
|
+
out.push({ category, reason: reason || undefined, item: ref ? Number(ref[2]) : undefined, ...(ref?.[1] ? { suite: ref[1] } : {}) });
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Loading a dataset and turning failed attempts into cases. Shared by the eval, the
|
|
2
|
+
// evidence harvester and the outlets.
|
|
3
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { join, relative } from 'node:path';
|
|
5
|
+
import { datasetFiles, PROJECT_ROOT, readJsonl, readRunSummaries, readTriageRecords } from '../reporters/store.js';
|
|
6
|
+
/**
|
|
7
|
+
* One case per failed test per run: its last failed attempt. Retries of the same failure
|
|
8
|
+
* share one cause, so counting each would weight a test by its retry count.
|
|
9
|
+
*/
|
|
10
|
+
export function lastFailedAttempts(records) {
|
|
11
|
+
const last = new Map();
|
|
12
|
+
for (const r of records) {
|
|
13
|
+
const key = `${r.runId} ${r.testId}`;
|
|
14
|
+
const seen = last.get(key);
|
|
15
|
+
if (!seen || r.retry > seen.retry)
|
|
16
|
+
last.set(key, r);
|
|
17
|
+
}
|
|
18
|
+
return [...last.values()];
|
|
19
|
+
}
|
|
20
|
+
/** Harvested datasets predate RunSummary.branch: fill it from the harvester's runs-meta.jsonl. */
|
|
21
|
+
function withBranches(summaries, dir) {
|
|
22
|
+
const metaFile = join(dir, 'runs-meta.jsonl');
|
|
23
|
+
if (!existsSync(metaFile))
|
|
24
|
+
return summaries;
|
|
25
|
+
const branch = new Map(readJsonl(metaFile).map((m) => [m.runId, m.branch ?? '']));
|
|
26
|
+
return summaries.map((s) => (s.branch || !branch.get(s.runId) ? s : { ...s, branch: branch.get(s.runId) }));
|
|
27
|
+
}
|
|
28
|
+
/** Records + run summaries from a dataset dir, or from every dataset directly under it. */
|
|
29
|
+
export function loadDataset(dir) {
|
|
30
|
+
const files = datasetFiles(dir);
|
|
31
|
+
if (!dir || existsSync(files.triage)) {
|
|
32
|
+
return { records: readTriageRecords(files.triage), summaries: withBranches(readRunSummaries(files.runs), files.dir), label: relative(PROJECT_ROOT, files.dir).replaceAll('\\', '/') || '.' };
|
|
33
|
+
}
|
|
34
|
+
const subdirs = readdirSync(files.dir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync(join(files.dir, d.name, 'triage.jsonl')));
|
|
35
|
+
const parts = subdirs.map((d) => datasetFiles(join(files.dir, d.name)));
|
|
36
|
+
return {
|
|
37
|
+
records: parts.flatMap((p) => readTriageRecords(p.triage)),
|
|
38
|
+
summaries: parts.flatMap((p) => withBranches(readRunSummaries(p.runs), p.dir)),
|
|
39
|
+
label: subdirs.map((d) => relative(PROJECT_ROOT, join(files.dir, d.name)).replaceAll('\\', '/')).join(', '),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/** Above this, code fixes (locator / wait) may be applied by the agent itself. */
|
|
2
|
+
export const AUTO_FIX_ABOVE = 0.85;
|
|
3
|
+
/** Below this, the diagnosis is too weak to act on: a human takes over. */
|
|
4
|
+
export const ESCALATE_BELOW = 0.5;
|
|
5
|
+
const pct = (n) => n.toFixed(2);
|
|
6
|
+
/**
|
|
7
|
+
* VERIFY -> DECIDE: what re-running the test with the suggested locator says about a drift
|
|
8
|
+
* diagnosis. Passing confirms the suggestion; failing with every candidate means the element was
|
|
9
|
+
* probably not renamed, so a human should check whether the feature itself broke.
|
|
10
|
+
*/
|
|
11
|
+
export function reviseAfterVerification(action, outcome) {
|
|
12
|
+
if (action.kind !== 'fix-locator')
|
|
13
|
+
return { action, note: null };
|
|
14
|
+
if (outcome.status === 'fixed' && outcome.replacement) {
|
|
15
|
+
return {
|
|
16
|
+
action: { ...action, summary: `Verified by re-running the test: with ${outcome.replacement} it passes. ${action.summary}`, reason: `${action.reason}; suggestion verified by a re-run` },
|
|
17
|
+
note: `re-running the test with ${outcome.replacement} passed`,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (outcome.status === 'all-candidates-failed') {
|
|
21
|
+
return {
|
|
22
|
+
action: {
|
|
23
|
+
kind: 'escalate',
|
|
24
|
+
owner: 'sdet',
|
|
25
|
+
autonomous: false,
|
|
26
|
+
summary: 'The test still fails with the most similar elements on the page, so this is probably not a renamed element: check whether the feature itself still works (possible real bug) before touching the test.',
|
|
27
|
+
reason: `${action.reason}; every suggested locator failed a re-run`,
|
|
28
|
+
},
|
|
29
|
+
note: 'every suggested replacement locator failed when the test was re-run',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (outcome.status === 'no-assertion') {
|
|
33
|
+
return {
|
|
34
|
+
action: { ...action, summary: `${action.summary} Cannot be verified automatically: the test has no assertion after this step, so check by hand that the new element is the intended one.` },
|
|
35
|
+
note: 'the suggestion cannot be verified: the test has no assertion after the failing step',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { action, note: null };
|
|
39
|
+
}
|
|
40
|
+
export function decide(d, hints = {}) {
|
|
41
|
+
const conf = `${d.category} at confidence ${pct(d.confidence)}`;
|
|
42
|
+
const target = hints.locator ? ` ${hints.locator}` : '';
|
|
43
|
+
if (d.category === 'UNKNOWN' || d.confidence < ESCALATE_BELOW) {
|
|
44
|
+
return {
|
|
45
|
+
kind: 'escalate',
|
|
46
|
+
owner: 'sdet',
|
|
47
|
+
autonomous: false,
|
|
48
|
+
summary: 'Needs a human decision: the agent could not classify this confidently. Start from the evidence below.',
|
|
49
|
+
reason: d.category === 'UNKNOWN' ? 'no rule or model decided' : `${conf} is below ${ESCALATE_BELOW}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
switch (d.category) {
|
|
53
|
+
case 'ENV':
|
|
54
|
+
return {
|
|
55
|
+
kind: 'rerun-and-notify',
|
|
56
|
+
owner: 'devops',
|
|
57
|
+
autonomous: true,
|
|
58
|
+
summary: 'Do not touch the test. Re-run it once; if it fails again, check the failing dependency named in the evidence and send one grouped notice.',
|
|
59
|
+
reason: `${conf}: environment problems are re-run and reported, never fixed in test code`,
|
|
60
|
+
};
|
|
61
|
+
case 'REAL_BUG':
|
|
62
|
+
return {
|
|
63
|
+
kind: 'file-bug',
|
|
64
|
+
owner: 'developer',
|
|
65
|
+
autonomous: true,
|
|
66
|
+
summary: `File a bug${hints.component ? ` against ${hints.component}` : ''}${hints.commit ? ` for commit ${hints.commit.slice(0, 8)}` : ''}${hints.tracePath ? ` with the trace (${hints.tracePath}) attached` : ' with the failure output attached'}; do not change the test.`,
|
|
67
|
+
reason: `${conf}: the application is broken, so the test stays as it is`,
|
|
68
|
+
};
|
|
69
|
+
case 'LOCATOR_DRIFT': {
|
|
70
|
+
// On real CI data the AI alone called real bugs "drift" at 0.88-0.96, while every correct
|
|
71
|
+
// lab fix came from the deterministic rule that found the renamed element on the page.
|
|
72
|
+
// So code changes need that rule's match, not just a high confidence.
|
|
73
|
+
const matched = d.source === 'rules' && (d.ruleId === 'locator-drift' || d.ruleId === 'locator-drift-strict');
|
|
74
|
+
const auto = d.confidence > AUTO_FIX_ABOVE && matched;
|
|
75
|
+
// A locator that now matches several elements needs narrowing, not renaming.
|
|
76
|
+
const fix = hints.strictMatches && hints.suggestedLocator
|
|
77
|
+
? `Change the locator${target} to ${hints.suggestedLocator}: it now matches ${hints.strictMatches} elements, and this is the one the test means`
|
|
78
|
+
: hints.strictMatches
|
|
79
|
+
? `Make the locator${target} more specific: it now matches ${hints.strictMatches} elements (add the element's name, a parent, or exact: true)`
|
|
80
|
+
: hints.suggestedLocator
|
|
81
|
+
? `Update the locator${target} to ${hints.suggestedLocator}`
|
|
82
|
+
: `Update the locator${target} to match the renamed element`;
|
|
83
|
+
return {
|
|
84
|
+
kind: 'fix-locator',
|
|
85
|
+
owner: 'sdet',
|
|
86
|
+
autonomous: auto,
|
|
87
|
+
summary: `${fix}, re-run the test, and open a PR only if it passes.`,
|
|
88
|
+
reason: auto
|
|
89
|
+
? `${conf} is above ${AUTO_FIX_ABOVE} and the page has a matching renamed element: auto-fix eligible`
|
|
90
|
+
: d.source === 'human'
|
|
91
|
+
? // A person's category is authoritative, but it does not name a replacement locator
|
|
92
|
+
// that a re-run has proved, which is what the agent needs before editing test code.
|
|
93
|
+
'a person corrected this to LOCATOR_DRIFT, which settles the cause but not the new locator: an SDET writes the fix'
|
|
94
|
+
: !matched
|
|
95
|
+
? `${conf}, but no deterministic match of a renamed element on the page: an SDET reviews the fix`
|
|
96
|
+
: `${conf} is below ${AUTO_FIX_ABOVE}: an SDET reviews the fix`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
case 'TIMING': {
|
|
100
|
+
const auto = d.confidence > AUTO_FIX_ABOVE && Boolean(hints.hardWait);
|
|
101
|
+
return hints.hardWait
|
|
102
|
+
? {
|
|
103
|
+
kind: 'fix-wait',
|
|
104
|
+
owner: 'sdet',
|
|
105
|
+
autonomous: auto,
|
|
106
|
+
summary: `Replace the fixed wait (${hints.hardWait}) with a web-first assertion on the element it waits for, re-run, and open a PR only if it passes.`,
|
|
107
|
+
reason: auto ? `${conf} is above ${AUTO_FIX_ABOVE}: auto-fix eligible (Phase 5)` : `${conf}: an SDET reviews the fix`,
|
|
108
|
+
}
|
|
109
|
+
: {
|
|
110
|
+
kind: 'stabilize-test',
|
|
111
|
+
owner: 'sdet',
|
|
112
|
+
autonomous: false,
|
|
113
|
+
summary: hints.locator
|
|
114
|
+
? `Make the step${target} wait for the element's ready state (e.g. await expect(locator).toBeVisible()) before acting; quarantine the test if it keeps flaking.`
|
|
115
|
+
: // Not a browser step (a unit or API test in any language): the race is in shared state,
|
|
116
|
+
// ordering or timing, and the fix is to wait for the condition instead of hoping.
|
|
117
|
+
'Find what the test races against (shared state, test order, a timer, a service still starting) and make it wait for that condition instead of a fixed delay; quarantine the test if it keeps flaking.',
|
|
118
|
+
reason: `${conf}: race without a hard-coded wait to replace, so there is no safe automatic fix`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
case 'TEST_DATA':
|
|
122
|
+
return {
|
|
123
|
+
kind: 'fix-precondition',
|
|
124
|
+
owner: 'sdet',
|
|
125
|
+
autonomous: false,
|
|
126
|
+
summary: 'Fix the test setup: create or reset the data the test assumes (see evidence) before it runs; do not change the assertion.',
|
|
127
|
+
reason: `${conf}: the product is fine, the test's starting data is wrong`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Deterministic readers for mobile test failures that are about the device, not the app: no
|
|
2
|
+
// emulator or simulator, the app never installed or crashed on launch, the driver could not be
|
|
3
|
+
// reached, the test sandbox could not be built. These are environment problems, the same kind as a
|
|
4
|
+
// refused connection, and they say nothing about the code under test.
|
|
5
|
+
// Checked against real messages pasted into public issues (Appium, Espresso, Detox, XCUITest,
|
|
6
|
+
// xcodebuild, Robolectric); every pattern below came from one of them.
|
|
7
|
+
const DEVICE_PATTERNS = [
|
|
8
|
+
// No device or simulator to run on: Appium, Flutter, Maestro, xcodebuild.
|
|
9
|
+
{
|
|
10
|
+
kind: 'no-device',
|
|
11
|
+
confidence: 0.9,
|
|
12
|
+
pattern: /Could not find a connected (?:Android|iOS) device[^\n]*|Device \S+ was not in the list of connected devices|No connected devices|Unable to boot the Simulator|Failed to prepare device [^\n]*for impending launch|no devices found|adb: device offline|device unauthorized|An unknown server-side error occurred[^\n]*Could not find a \w+ device/i,
|
|
13
|
+
},
|
|
14
|
+
// The app under test never started, or died during the run.
|
|
15
|
+
{
|
|
16
|
+
kind: 'app-crashed',
|
|
17
|
+
confidence: 0.85,
|
|
18
|
+
pattern: /The application under test with bundle id [^\n]*is not running, possibly crashed|WDAError\(status=889[^\n]*|your main activity has crashed prematurely|Application is not running, possibly crashed|instrumentation process (?:crashed|is not responding)/i,
|
|
19
|
+
},
|
|
20
|
+
// The build or test runner could not be put on the device.
|
|
21
|
+
{
|
|
22
|
+
kind: 'install-failed',
|
|
23
|
+
confidence: 0.85,
|
|
24
|
+
pattern: /Failed to install or launch the test runner|INSTALL_FAILED_\w+|Failed to run application on the device|Could not install [^\n]*apk|Unable to install [^\n]*\.app/i,
|
|
25
|
+
},
|
|
26
|
+
// The automation server itself is unreachable (Appium, WebDriverAgent).
|
|
27
|
+
{ kind: 'driver-unreachable', confidence: 0.85, pattern: /Failed to connect to the appium server[^\n]*|Could not proxy command to the remote server|WebDriverAgent[^\n]*(?:failed to start|is not reachable)/i },
|
|
28
|
+
// The test sandbox could not be built at all: a toolchain mismatch, not a test failure.
|
|
29
|
+
{ kind: 'sandbox', confidence: 0.9, pattern: /Failed to create a Robolectric sandbox[^\n]*|Android SDK \d+ requires Java \d+ \(have Java \d+\)/i },
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* A mobile failure caused by the device, the install or the driver, strongest first. Detox prints
|
|
33
|
+
* the same error for a crash and for a test timeout, telling them apart only in its HINT line, so
|
|
34
|
+
* that line decides: a timed-out test is the test's problem and gets no device signal here.
|
|
35
|
+
*/
|
|
36
|
+
export function deviceEnvSignal(message) {
|
|
37
|
+
if (/your tests have timed out and called detox\.cleanup\(\)/i.test(message))
|
|
38
|
+
return null;
|
|
39
|
+
for (const { kind, confidence, pattern } of DEVICE_PATTERNS) {
|
|
40
|
+
const match = message.match(pattern)?.[0];
|
|
41
|
+
if (match)
|
|
42
|
+
return { kind, confidence, match: match.replace(/\s+/g, ' ').trim().slice(0, 160) };
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|