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.
Files changed (66) hide show
  1. package/LICENSE +202 -0
  2. package/QUICKSTART.md +178 -0
  3. package/README.md +308 -0
  4. package/TEAM-GUIDE.md +234 -0
  5. package/dist/cli/tewip.js +117 -0
  6. package/dist/engine/autofix/autofix.js +63 -0
  7. package/dist/engine/autofix/candidates.js +33 -0
  8. package/dist/engine/autofix/locate.js +78 -0
  9. package/dist/engine/corrections.js +66 -0
  10. package/dist/engine/dataset.js +41 -0
  11. package/dist/engine/decide.js +130 -0
  12. package/dist/engine/device-errors.js +45 -0
  13. package/dist/engine/drift-rules.js +98 -0
  14. package/dist/engine/driver-errors.js +103 -0
  15. package/dist/engine/engine.js +76 -0
  16. package/dist/engine/error-parse.js +150 -0
  17. package/dist/engine/history.js +105 -0
  18. package/dist/engine/import/blob.js +165 -0
  19. package/dist/engine/import/junit.js +235 -0
  20. package/dist/engine/import/xml.js +104 -0
  21. package/dist/engine/llm/anthropic.js +59 -0
  22. package/dist/engine/llm/client.js +139 -0
  23. package/dist/engine/llm/context.js +141 -0
  24. package/dist/engine/llm/llm-diagnose.js +48 -0
  25. package/dist/engine/llm/prompt.js +40 -0
  26. package/dist/engine/llm/providers.js +54 -0
  27. package/dist/engine/llm/usage.js +69 -0
  28. package/dist/engine/pipeline.js +33 -0
  29. package/dist/engine/quality/frameworks.js +181 -0
  30. package/dist/engine/quality/grounding.js +203 -0
  31. package/dist/engine/quality/model.js +17 -0
  32. package/dist/engine/quality/plan-command.js +34 -0
  33. package/dist/engine/quality/plan-render.js +38 -0
  34. package/dist/engine/quality/plan.js +119 -0
  35. package/dist/engine/quality/write-command.js +146 -0
  36. package/dist/engine/quality/write-verify.js +140 -0
  37. package/dist/engine/quality/write.js +144 -0
  38. package/dist/engine/rules.js +174 -0
  39. package/dist/engine/signature.js +23 -0
  40. package/dist/engine/similarity.js +76 -0
  41. package/dist/engine/snapshot.js +56 -0
  42. package/dist/engine/timing-rules.js +188 -0
  43. package/dist/engine/trace/dom.js +92 -0
  44. package/dist/engine/trace/trace-summary.js +87 -0
  45. package/dist/engine/trace/zip.js +53 -0
  46. package/dist/eval/harvest/gh.js +21 -0
  47. package/dist/eval/harvest/harvest.js +117 -0
  48. package/dist/mcp/github-report.js +50 -0
  49. package/dist/mcp/server.js +80 -0
  50. package/dist/mcp/tools.js +124 -0
  51. package/dist/outlets/design-debt.js +22 -0
  52. package/dist/outlets/items.js +94 -0
  53. package/dist/outlets/view.js +63 -0
  54. package/dist/outlets/views/architecture.js +77 -0
  55. package/dist/outlets/views/developer.js +59 -0
  56. package/dist/outlets/views/digest.js +66 -0
  57. package/dist/outlets/views/infra.js +49 -0
  58. package/dist/outlets/views/quality.js +108 -0
  59. package/dist/outlets/views/release.js +33 -0
  60. package/dist/outlets/views/sdet.js +29 -0
  61. package/dist/reporters/run-context.js +20 -0
  62. package/dist/reporters/store.js +46 -0
  63. package/dist/reporters/triage-reporter.js +101 -0
  64. package/dist/reporters/types.js +4 -0
  65. package/dist/scripts/explain.js +58 -0
  66. package/package.json +81 -0
@@ -0,0 +1,146 @@
1
+ // `tewip write`: turn a plan's "to automate next" scenarios into tests in the project's own
2
+ // framework. Kept tests stay beside the project's tests for the team to review and commit; every
3
+ // other draft is moved to .tewip/drafts with the reason, and nothing is committed or pushed.
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
5
+ import { dirname, extname, join, relative } from 'node:path';
6
+ import { noModelHelp } from '../llm/providers.js';
7
+ import { usageLine } from '../llm/usage.js';
8
+ import { chooseFramework, detectFrameworks } from './frameworks.js';
9
+ import { readPages, relatedCodeSources, relatedTestSources } from './grounding.js';
10
+ import { writerFromEnv } from './model.js';
11
+ import { writeAndVerify } from './write-verify.js';
12
+ function latestPlan(root) {
13
+ const dir = join(root, '.tewip', 'plans');
14
+ const plans = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => join(dir, f)) : [];
15
+ if (plans.length === 0)
16
+ throw new Error('no plan to write from: run `tewip plan "<feature>"` first, or pass --plan <file.json>');
17
+ return plans.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
18
+ }
19
+ export async function runWrite(cmd) {
20
+ const model = writerFromEnv(cmd.root);
21
+ if (!model)
22
+ throw new Error(`writing tests needs a model.
23
+ ${noModelHelp()}`);
24
+ const planPath = cmd.plan ?? latestPlan(cmd.root);
25
+ const saved = JSON.parse(readFileSync(planPath, 'utf8'));
26
+ const found = detectFrameworks(cmd.root);
27
+ const urls = cmd.urls.length ? cmd.urls : saved.urls;
28
+ const detected = chooseFramework(found, cmd.framework, urls.length > 0);
29
+ if (!detected) {
30
+ const seen = found.map((f) => f.id).join(', ') || 'none';
31
+ throw new Error(`no ${cmd.framework ?? 'test'} framework found to write in (found: ${seen}). Tewip writes Playwright, Cypress, WebdriverIO, Angular (ng test), Jest, Vitest, pytest, JUnit (Maven/Gradle), .NET and Go tests.`);
32
+ }
33
+ // Copy the style of the existing test closest to the feature, when it is one of this framework's
34
+ // tests: it shows the helpers and conventions this part of the code base is tested with.
35
+ // Only a test this framework runs: a browser framework runs what is in its test folder; a unit
36
+ // framework runs tests anywhere in its project.
37
+ const within = detected.browser ? detected.testDir : detected.cwd;
38
+ // .ts and .tsx (or .js and .jsx) are the same language; the x only allows JSX, which component tests need.
39
+ const family = (f) => extname(f).replace(/x$/, '');
40
+ const sameKind = (f) => detected.example !== null && family(f) === family(detected.example) && f.startsWith(within);
41
+ const closest = relatedTestSources(cmd.root, saved.feature, 10).map((t) => join(cmd.root, t.file)).find(sameKind);
42
+ // New tests take the example's own extension, so a component test gets .tsx when its neighbours do.
43
+ const fileFor = (id) => (closest ? detected.fileFor(id).replace(/\.[^.]+$/, extname(closest)) : detected.fileFor(id));
44
+ const framework = closest ? { ...detected, example: closest, testDir: dirname(closest), fileFor } : detected;
45
+ const wanted = new Set(cmd.only.map((s) => s.toUpperCase()));
46
+ const scenarios = saved.plan.scenarios.filter((s) => (wanted.size ? wanted.has(s.id.toUpperCase()) : s.automate && !s.covered_by)).slice(0, cmd.max);
47
+ if (scenarios.length === 0)
48
+ throw new Error(wanted.size ? `none of ${cmd.only.join(', ')} is in ${planPath}` : 'the plan has nothing left to automate');
49
+ const code = relatedCodeSources(cmd.root, saved.feature);
50
+ const { pages, note } = await readPages(urls, cmd.root);
51
+ console.error(`Tewip is writing ${scenarios.length} test(s) in ${framework.label}, in ${relative(cmd.root, framework.testDir) || '.'}${framework.example ? `, in the style of ${relative(cmd.root, framework.example)}` : ''}.`);
52
+ if (note)
53
+ console.error(` ${note}`);
54
+ if (!framework.browser && pages.length)
55
+ console.error(' note: this framework does not drive a browser; pass --framework to pick a browser one if the scenarios need pages.');
56
+ const drafts = join(cmd.root, '.tewip', 'drafts');
57
+ const results = [];
58
+ const started = Date.now();
59
+ let done = 0;
60
+ for (const scenario of scenarios) {
61
+ const fileName = framework.fileFor(scenario.id);
62
+ const file = join(framework.testDir, fileName);
63
+ if (existsSync(file) || (cmd.moveTo && existsSync(join(cmd.moveTo, fileName)))) {
64
+ results.push({ id: scenario.id, title: scenario.title, file, outcome: { kind: 'rejected', reason: `${fileName} already exists; left untouched`, repairs: 0, passedReliably: false } });
65
+ continue;
66
+ }
67
+ console.error(` ${scenario.id} ${scenario.title}`);
68
+ // Stopped part-way (Ctrl+C), a draft that was never verified must not stay in the project.
69
+ const leaveNothing = () => {
70
+ if (existsSync(file)) {
71
+ mkdirSync(drafts, { recursive: true });
72
+ renameSync(file, join(drafts, fileName));
73
+ }
74
+ process.exit(130);
75
+ };
76
+ process.once('SIGINT', leaveNothing).once('SIGTERM', leaveNothing);
77
+ let outcome;
78
+ try {
79
+ ({ outcome } = await writeAndVerify(model, { framework, fileName, pages, feature: saved.feature, code }, scenario, file, { runs: cmd.runs, repairs: cmd.repairs, log: (l) => console.error(l) }));
80
+ }
81
+ catch (error) {
82
+ outcome = { kind: 'rejected', reason: `stopped: ${error.message}`, repairs: 0, passedReliably: false };
83
+ }
84
+ finally {
85
+ process.off('SIGINT', leaveNothing).off('SIGTERM', leaveNothing);
86
+ }
87
+ // Some runners take minutes per run (Angular rebuilds the app for every `ng test`): say how long
88
+ // the rest will take, so a slow run is not mistaken for a stuck one.
89
+ done++;
90
+ const left = scenarios.length - results.length - 1;
91
+ if (done === 1 && left > 0)
92
+ console.error(` (about ${Math.max(1, Math.round((Date.now() - started) / 60_000))} min per scenario here; ${left} more to go)`);
93
+ if (outcome.kind !== 'kept' && existsSync(file)) {
94
+ mkdirSync(drafts, { recursive: true });
95
+ renameSync(file, join(drafts, fileName));
96
+ if ('lastError' in outcome && outcome.lastError)
97
+ writeFileSync(join(drafts, `${fileName}.log`), outcome.lastError, 'utf8');
98
+ }
99
+ if (outcome.kind === 'kept' && cmd.moveTo) {
100
+ mkdirSync(cmd.moveTo, { recursive: true });
101
+ renameSync(file, join(cmd.moveTo, fileName));
102
+ results.push({ id: scenario.id, title: scenario.title, file: join(cmd.moveTo, fileName), outcome });
103
+ console.error(` → kept, moved to ${relative(cmd.root, cmd.moveTo) || '.'}`);
104
+ continue;
105
+ }
106
+ console.error(` → ${outcome.kind}${outcome.kind === 'kept' ? '' : `: ${outcome.reason}`}`);
107
+ results.push({ id: scenario.id, title: scenario.title, file, outcome });
108
+ }
109
+ const report = renderWriteReport(results, { root: cmd.root, planPath, framework: framework.label, runs: cmd.runs, model: model.model });
110
+ const out = join(cmd.root, '.tewip', 'write', `${new Date().toISOString().replace(/[:.]/g, '-')}.md`);
111
+ mkdirSync(join(out, '..'), { recursive: true });
112
+ writeFileSync(out, report, 'utf8');
113
+ console.log(report);
114
+ console.error(`\nSaved to ${out}\n${usageLine()}`);
115
+ }
116
+ export function renderWriteReport(results, meta) {
117
+ const kept = results.filter((r) => r.outcome.kind === 'kept');
118
+ const bugs = results.filter((r) => r.outcome.kind === 'possible-bug');
119
+ const reliable = results.filter((r) => r.outcome.kind === 'kept' || (r.outcome.kind === 'rejected' && r.outcome.passedReliably));
120
+ const reason = (r) => (r.outcome.kind === 'rejected' ? r.outcome.reason : '');
121
+ const cannotFail = reliable.filter((r) => reason(r).startsWith('still passes')).length;
122
+ const unproven = reliable.length - kept.length - cannotFail;
123
+ const lines = [
124
+ '# Tewip: tests written',
125
+ '',
126
+ `From ${relative(meta.root, meta.planPath)}, in ${meta.framework}, by ${meta.model}. A test is kept only if it passed every run against the working app (${meta.runs} runs, or 2 where a single run takes over 90 seconds) **and** failed once its main check was broken on purpose.`,
127
+ '',
128
+ `- **Kept: ${kept.length} of ${results.length} drafts.**`,
129
+ `- Mutation check: ${kept.length} of ${reliable.length} test(s) that passed reliably also failed when their check was broken.${cannotFail ? ` ${cannotFail} still passed with it broken, so they could not catch the bug and were dropped.` : ''}${unproven ? ` ${unproven} could not be checked (the break could not be applied or did not run) and were dropped unproven.` : ''}`,
130
+ ...(bugs.length ? [`- **Possible bugs: ${bugs.length}.** The test looked right and the app did something else; a person should look.`] : []),
131
+ '',
132
+ '| Scenario | Result | Why |',
133
+ '|---|---|---|',
134
+ ...results.map((r) => {
135
+ const o = r.outcome;
136
+ const watch = o.kind === 'kept' && o.watch.length ? ` **Review:** may break when the data changes, not the app: \`${o.watch.join('`, `')}\`` : '';
137
+ const why = o.kind === 'kept' ? `passed ${o.runs} run(s); checks: ${o.checks}${o.repairs ? ` (after ${o.repairs} repair(s))` : ''}${watch}` : o.reason;
138
+ const where = o.kind === 'kept' ? `kept: \`${relative(meta.root, r.file).replaceAll('\\', '/')}\`` : o.kind === 'possible-bug' ? '**possible bug**' : 'not kept';
139
+ return `| ${r.id} ${r.title.replaceAll('|', '/')} | ${where} | ${why.replace(/\s+/g, ' ').replaceAll('|', '/')} |`;
140
+ }),
141
+ '',
142
+ 'Kept tests are new files (beside your tests, or where --move-to put them): review, then commit them yourself. Drafts not kept are in `.tewip/drafts/` with their last error. The broken-on-purpose change is chosen by the model, so it shows the test can fail on its own main check, not that it would catch every bug.',
143
+ '',
144
+ ];
145
+ return lines.join('\n');
146
+ }
@@ -0,0 +1,140 @@
1
+ // The verification half of `tewip write`: a draft is kept only when it passes against the working
2
+ // app every time it is run, and fails once its main check is broken on purpose. Nothing here asks
3
+ // the model whether a test is good; it runs the test and reads the result.
4
+ import { spawnSync } from 'node:child_process';
5
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { isAbsolute, join } from 'node:path';
7
+ import { brittleChecks, draftTest, failureHead, mutate, remutate, repairTest, weakChecks as weakOf } from './write.js';
8
+ /**
9
+ * Every test in the run was skipped (a runner exits 0 for that): nothing was checked, so it is not
10
+ * a pass. Playwright, pytest and Jest print "1 skipped", Mocha "1 pending", Maven "Skipped: 1".
11
+ */
12
+ export function onlySkipped(output) {
13
+ const karma = output.match(/Executed (\d+) of (\d+) \(skipped (\d+)\)/);
14
+ if (karma)
15
+ return karma[1] === '0';
16
+ const maven = output.match(/Tests run: (\d+), Failures: 0, Errors: 0, Skipped: (\d+)/);
17
+ if (maven)
18
+ return maven[1] === maven[2] && maven[1] !== '0';
19
+ return /\b[1-9]\d* (?:skipped|pending)\b/i.test(output) && !/\b[1-9]\d* pass(?:ed|ing)\b/i.test(output);
20
+ }
21
+ /** The page as it was when the test failed, when the runner saved it (Playwright's error context). */
22
+ function pageAtFailure(output, cwd) {
23
+ const path = output.match(/Error Context: (.+\.md)/)?.[1]?.trim();
24
+ if (!path)
25
+ return '';
26
+ const full = isAbsolute(path) ? path : join(cwd, path);
27
+ return existsSync(full) ? `\n\nThe page when the test failed:\n${readFileSync(full, 'utf8').slice(0, 5000)}` : '';
28
+ }
29
+ /**
30
+ * The runner itself broke before or around the test: the browser never started, a port was taken,
31
+ * the app's server did not come up, the run was cut off. That says nothing about the test, so the
32
+ * run is tried again, and if it keeps breaking, the environment is named, never the test.
33
+ */
34
+ const RUNNER_BROKE = /has not captured in \d+ ?ms|failed \d+ times \(timeout\)\. Giving up|Disconnected[, ].{0,40}because no message|No captured browser|EADDRINUSE|Timed out waiting \d+ms from config\.webServer|webServer.{0,60}(?:exited|timed out)|ENOSPC|spawnSync .* ETIMEDOUT/i;
35
+ export function runTest(framework, file, timeoutMs = 180_000) {
36
+ let result = runOnce(framework, file, timeoutMs);
37
+ for (let retry = 0; retry < 2 && !result.passed && RUNNER_BROKE.test(result.output); retry++)
38
+ result = runOnce(framework, file, timeoutMs);
39
+ if (!result.passed && RUNNER_BROKE.test(result.output))
40
+ return { ...result, environment: true };
41
+ return result;
42
+ }
43
+ function runOnce(framework, file, timeoutMs) {
44
+ const r = spawnSync(framework.command(file), { cwd: framework.cwd, env: { ...process.env, ...framework.env }, shell: true, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024 });
45
+ // Colour codes are noise to a reader and would stop a quoted line from matching.
46
+ const output = `${r.stdout ?? ''}\n${r.stderr ?? ''}${r.error ? `\n${r.error.message}` : ''}`.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '').trim();
47
+ if (r.status === 0 && onlySkipped(output))
48
+ return { passed: false, skipped: true, output: `The test was skipped, so nothing was checked. Make it run here.\n${output.slice(-3000)}` };
49
+ return { passed: r.status === 0, output: output.slice(-6000) + (r.status === 0 ? '' : pageAtFailure(output, framework.cwd)) };
50
+ }
51
+ /** Above this, one run of the test is slow enough to trade a reliability run for time. */
52
+ const SLOW_RUN_MS = 90_000;
53
+ /** The broken version did not run at all, so its failure proves nothing about the check. */
54
+ const DID_NOT_RUN = /SyntaxError|error TS\d{4}|IndentationError|NameError|ReferenceError|Unexpected token|compilation (?:error|failure)|cannot find symbol|error CS\d{4}/;
55
+ export async function writeAndVerify(model, ctx, scenario, file, opts) {
56
+ const run = opts.run ?? runTest;
57
+ const log = opts.log ?? (() => { });
58
+ let draft = await draftTest(model, ctx, scenario);
59
+ let repairs = 0;
60
+ let result;
61
+ const earlier = [];
62
+ let slowest = 0;
63
+ for (;;) {
64
+ writeFileSync(file, draft.code, 'utf8');
65
+ const startedAt = Date.now();
66
+ result = run(ctx.framework, file);
67
+ slowest = Math.max(slowest, Date.now() - startedAt);
68
+ if (result.passed)
69
+ break;
70
+ if (result.environment)
71
+ return { draft, outcome: { kind: 'rejected', reason: 'the test runner could not start (environment: browser, server or port), so the test was not judged', repairs, lastError: result.output, passedReliably: false } };
72
+ if (repairs >= opts.repairs)
73
+ return { draft, outcome: { kind: 'rejected', reason: `still failing after ${repairs} repair(s)`, repairs, lastError: result.output, passedReliably: false } };
74
+ repairs++;
75
+ log(` failed; repair ${repairs} of ${opts.repairs} from the real error`);
76
+ const repaired = await repairTest(model, ctx, scenario, draft, result.output, earlier);
77
+ earlier.push(failureHead(result.output));
78
+ if (repaired.fault === 'app') {
79
+ // The model's word is not enough: its quote must really be in what the run printed.
80
+ // A bare label ("Received:") is in every failure and shows nothing, so the quote must say more.
81
+ const quote = (repaired.observed ?? '').trim();
82
+ const telling = quote.length >= 16 && quote.replace(/\b(?:Received|Expected|Error|Actual|Call log)\b|[\s:'"`.,-]/g, '').length >= 8;
83
+ if (telling && result.output.includes(quote))
84
+ return { draft, outcome: { kind: 'possible-bug', reason: `${repaired.reason} (seen: "${quote.slice(0, 200)}")`, repairs, lastError: result.output } };
85
+ return { draft, outcome: { kind: 'rejected', reason: `the model blamed the app but could not quote the run's output to show it, so it is not reported as a bug: ${repaired.reason.slice(0, 200)}`, repairs, lastError: result.output, passedReliably: false } };
86
+ }
87
+ draft = repaired;
88
+ }
89
+ // A runner that takes minutes per run (Angular rebuilds the app each time) gets one reliability run
90
+ // less: the report always says how many runs a kept test passed.
91
+ const runs = slowest > SLOW_RUN_MS ? Math.min(opts.runs, 2) : opts.runs;
92
+ if (runs < opts.runs)
93
+ log(` each run takes about ${Math.round(slowest / 1000)}s here, so ${runs} runs instead of ${opts.runs}`);
94
+ for (let i = 2; i <= runs; i++) {
95
+ const again = run(ctx.framework, file);
96
+ if (again.environment)
97
+ return { draft, outcome: { kind: 'rejected', reason: `the test runner broke on run ${i} (environment: browser, server or port), so reliability was not judged`, repairs, lastError: again.output, passedReliably: false } };
98
+ if (!again.passed)
99
+ return { draft, outcome: { kind: 'rejected', reason: `flaky: passed ${i - 1} time(s), then failed on run ${i}`, repairs, lastError: again.output, passedReliably: false } };
100
+ }
101
+ log(` passed ${runs} of ${runs} runs; breaking its check on purpose`);
102
+ let mutant = mutate(draft);
103
+ if ('problem' in mutant) {
104
+ log(` the deliberate break could not be applied (${mutant.problem}); asking once more`);
105
+ draft = await remutate(model, draft, mutant.problem);
106
+ mutant = mutate(draft);
107
+ }
108
+ if ('problem' in mutant)
109
+ return { draft, outcome: { kind: 'rejected', reason: `could not prove it can fail: ${mutant.problem}`, repairs, passedReliably: true } };
110
+ const runBroken = (code) => {
111
+ try {
112
+ writeFileSync(file, code, 'utf8');
113
+ return run(ctx.framework, file);
114
+ }
115
+ finally {
116
+ writeFileSync(file, draft.code, 'utf8');
117
+ }
118
+ };
119
+ let broken = runBroken(mutant.code);
120
+ // A break that does not compile (a type error, a syntax slip) proves nothing: ask once for another,
121
+ // showing the compiler's complaint.
122
+ if (!broken.passed && DID_NOT_RUN.test(broken.output)) {
123
+ log(' the deliberate break did not compile; asking once more');
124
+ draft = await remutate(model, draft, `the broken version did not compile or run: ${failureHead(broken.output)}`);
125
+ const again = mutate(draft);
126
+ if ('problem' in again)
127
+ return { draft, outcome: { kind: 'rejected', reason: `could not prove it can fail: ${again.problem}`, repairs, passedReliably: true } };
128
+ broken = runBroken(again.code);
129
+ }
130
+ if (broken.environment)
131
+ return { draft, outcome: { kind: 'rejected', reason: 'the test runner broke while checking the deliberate break (environment), so the check was not proven', repairs, lastError: broken.output, passedReliably: true } };
132
+ if (broken.skipped)
133
+ return { draft, outcome: { kind: 'rejected', reason: 'the broken version was skipped, so the check was not proven', repairs, passedReliably: true } };
134
+ if (broken.passed)
135
+ return { draft, outcome: { kind: 'rejected', reason: `still passes with its check broken (${draft.mutation.find} → ${draft.mutation.replace}): it could not catch the bug it is for`, repairs, passedReliably: true } };
136
+ if (DID_NOT_RUN.test(broken.output))
137
+ return { draft, outcome: { kind: 'rejected', reason: 'the broken version did not even run, so the check was not proven', repairs, lastError: broken.output, passedReliably: true } };
138
+ const weak = weakOf(draft.code);
139
+ return { draft, outcome: { kind: 'kept', runs, checks: draft.checks, repairs, mutation: `${draft.mutation.find} → ${draft.mutation.replace}`, watch: [...brittleChecks(draft.code), ...(weak ? [weak] : [])] } };
140
+ }
@@ -0,0 +1,144 @@
1
+ // Phase 7: the model's side of `tewip write`. It drafts one test for one plan scenario, in the
2
+ // project's own framework and style, using only elements seen on the real page, and names one
3
+ // deliberate break of its main check (the mutation) so Tewip can prove the test is able to fail.
4
+ // When a draft fails, the model repairs it from the real error - or says the app looks wrong,
5
+ // because weakening a test until it passes would hide exactly the bug it exists to catch.
6
+ import { readFileSync } from 'node:fs';
7
+ export const WRITE_PROMPT_VERSION = 'write-6';
8
+ const RULES = `Rules:
9
+ - Write in the given framework and language, in the style of the example test: the same imports, helpers, and way of opening pages (if it uses a base URL with relative paths, do the same).
10
+ - Find elements only by what the accessibility snapshots show: role and accessible name, label, placeholder or visible text. Never invent ids, classes or test ids that no snapshot shows.
11
+ - Check the scenario's expected result specifically. A check that is always true (a page merely loads, a list merely exists) is not a test.
12
+ - Wait for conditions, never for fixed times.
13
+ - Do not hard-code values that change as the app's content grows (counts, totals, dates, ids) unless the scenario is about that exact value: check their shape instead (a number of books, a date), so the test does not break the day a record is added.
14
+ - One test file, complete, runnable as is, no placeholders. It must actually run here: Tewip runs it against the app now, and a skipped test proves nothing. A guard like the example's (skip where the app lacks something) is fine, as long as it does not skip here.
15
+ - mutation.find must be an exact substring of your code that appears once, inside the main assertion (the value or condition it expects); mutation.replace is what to put there instead so that the test must fail if it is a real check. Keep the code valid: change an expected value, not the syntax.`;
16
+ const DRAFT_SYSTEM = `You are Tewip's test writer, a senior SDET. Write one automated test for the scenario given.\n\n${RULES}`;
17
+ const REPAIR_SYSTEM = `You are Tewip's test writer, a senior SDET. Your test failed when run against the working app. Read the real error and decide:
18
+ - fault "test": the test is wrong (a locator, a wait, a wrong assumption about the page). Return the corrected full test.
19
+ - fault "app": the test is right and the app does not do what the scenario expects. Return the test unchanged, say in reason what the app did instead, and put in observed an exact quote (copied character for character) from the real error or the page snapshot that shows it. Without such a quote, it is not an app fault. For fault "test", observed is empty.
20
+ If the code documents the behaviour the app showed (a comment or doc saying so), the scenario's expectation was wrong: the fault is "test"; correct the check to the documented behaviour and say so in reason.
21
+ Never weaken the check of the scenario's expected result to make it pass: that would hide the bug the test exists to catch. If the only way to pass is to stop checking the expected result, the fault is "app".\n\n${RULES}`;
22
+ const DRAFT_PROPS = {
23
+ code: { type: 'string' },
24
+ checks: { type: 'string' },
25
+ mutation: { type: 'object', additionalProperties: false, required: ['find', 'replace'], properties: { find: { type: 'string' }, replace: { type: 'string' } } },
26
+ };
27
+ const DRAFT_SCHEMA = { type: 'object', additionalProperties: false, required: ['code', 'checks', 'mutation'], properties: DRAFT_PROPS };
28
+ const REPAIR_SCHEMA = {
29
+ type: 'object',
30
+ additionalProperties: false,
31
+ required: ['fault', 'reason', 'observed', 'code', 'checks', 'mutation'],
32
+ properties: { fault: { type: 'string', enum: ['test', 'app'] }, reason: { type: 'string' }, observed: { type: 'string' }, ...DRAFT_PROPS },
33
+ };
34
+ function contextFor(ctx, scenario) {
35
+ const example = ctx.framework.example ? readFileSync(ctx.framework.example, 'utf8').slice(0, 6000) : 'none: write in the framework\'s usual style';
36
+ return {
37
+ framework: ctx.framework.label,
38
+ fileName: ctx.fileName,
39
+ note: /\.(java|kt|cs)$/.test(ctx.fileName) ? `the class must be named ${ctx.fileName.replace(/\.\w+$/, '')}` : undefined,
40
+ feature: ctx.feature,
41
+ scenario: { id: scenario.id, title: scenario.title, given: scenario.preconditions, steps: scenario.steps, expected: scenario.expected },
42
+ exampleTest: example,
43
+ pages: ctx.pages.map((p) => ({ readAt: p.url, title: p.title, accessibilitySnapshot: p.aria })),
44
+ codeUnderTest: ctx.code?.length ? ctx.code : undefined,
45
+ };
46
+ }
47
+ export async function draftTest(model, ctx, scenario) {
48
+ const raw = await model.complete({ system: DRAFT_SYSTEM, user: JSON.stringify(contextFor(ctx, scenario)), schemaName: 'tewip_test_draft', schema: DRAFT_SCHEMA });
49
+ return JSON.parse(raw);
50
+ }
51
+ /** The first lines of a failure: which check failed and how, without the page or the call log. */
52
+ export function failureHead(error) {
53
+ const start = error.search(/\b(?:\w*Error|FAILED|AssertionError|Exception)\b/);
54
+ return error.slice(Math.max(0, start)).split(/\n\s*(?:Call log:|at |The page when)/)[0].trim().slice(0, 800);
55
+ }
56
+ export async function repairTest(model, ctx, scenario, draft, error, earlier = []) {
57
+ // A check that keeps failing the same way after repairs is what the app consistently does: say so,
58
+ // so the model weighs "the app is wrong" instead of reshaping the test around the app.
59
+ const repeated = earlier.length > 0 ? { earlierFailures: earlier, note: 'Your earlier repairs failed too. If the same check keeps failing because the page does something other than the scenario expects, the fault is "app".' } : {};
60
+ const user = JSON.stringify({ ...contextFor(ctx, scenario), yourTest: draft.code, realError: error.slice(-10000), ...repeated });
61
+ const raw = await model.complete({ system: REPAIR_SYSTEM, user, schemaName: 'tewip_test_repair', schema: REPAIR_SCHEMA });
62
+ return JSON.parse(raw);
63
+ }
64
+ /**
65
+ * The broken-on-purpose version of a test, or why none could be made. The piece to change must
66
+ * occur exactly once, sit on a line that checks something, and actually change it.
67
+ */
68
+ export function mutate(draft) {
69
+ const { find, replace } = draft.mutation;
70
+ if (!find || find === replace)
71
+ return { problem: 'the mutation changes nothing' };
72
+ const lineAt = (at) => {
73
+ const end = draft.code.indexOf('\n', at);
74
+ return draft.code.slice(draft.code.lastIndexOf('\n', at) + 1, end < 0 ? undefined : end);
75
+ };
76
+ const places = [];
77
+ for (let at = draft.code.indexOf(find); at >= 0; at = draft.code.indexOf(find, at + 1))
78
+ places.push(at);
79
+ if (places.length === 0)
80
+ return { problem: `the piece to change ("${find.slice(0, 60)}") is not in the test` };
81
+ // Several places: fine when exactly one of them is in a check (the same value is often both the
82
+ // input and the expected result).
83
+ // A check may span lines (a multi-line expect, or `out:` above the expected value), so the two
84
+ // lines before the piece count too.
85
+ const withContext = (at) => {
86
+ const start = draft.code.lastIndexOf('\n', at);
87
+ const before = draft.code.slice(0, Math.max(0, start)).split('\n').slice(-2).join('\n');
88
+ return `${before}\n${lineAt(at)}`;
89
+ };
90
+ const inChecks = places.filter((at) => CHECK_LINE.test(withContext(at)));
91
+ if (inChecks.length !== 1) {
92
+ return { problem: inChecks.length === 0 ? `the piece to change is not in a check: ${lineAt(places[0]).trim().slice(0, 100)}` : `the piece to change ("${find.slice(0, 60)}") is in ${inChecks.length} checks; pick one exact place` };
93
+ }
94
+ const at = inChecks[0];
95
+ return { code: draft.code.slice(0, at) + replace + draft.code.slice(at + find.length) };
96
+ }
97
+ /** A line that checks a result: an assertion, or the expected column of a table-driven test. */
98
+ const CHECK_LINE = /expect|assert|should|verify|Assert|toHave|toBe|toEqual|toContain|toMatch|contains|Equal|raises|Raises|Throws|toThrow|[=!]==?|\b(?:out|output|expected|want|result)\s*[:=]/;
99
+ const MUTATION_SCHEMA = {
100
+ type: 'object',
101
+ additionalProperties: false,
102
+ required: ['mutation'],
103
+ properties: { mutation: DRAFT_PROPS.mutation },
104
+ };
105
+ /** One more try at a usable deliberate break, when the first could not be applied. */
106
+ export async function remutate(model, draft, problem) {
107
+ const user = JSON.stringify({ test: draft.code, yourMutation: draft.mutation, problem });
108
+ const system = 'You chose a deliberate break for this test so Tewip can prove it can fail, but it could not be applied. Choose again: mutation.find must be an exact substring of the test, inside the main check (an assertion, or the expected value in a table of cases), appearing exactly once inside a check; mutation.replace must make a correct test fail while keeping the code valid.';
109
+ const raw = await model.complete({ system, user, schemaName: 'tewip_mutation', schema: MUTATION_SCHEMA });
110
+ return { ...draft, mutation: JSON.parse(raw).mutation };
111
+ }
112
+ /**
113
+ * Checks that may break when the app's data changes rather than when it breaks: a number of three
114
+ * or more digits written into an expected text or value. Shown to the reviewer, not a reason to drop
115
+ * the test, since some scenarios are about an exact figure.
116
+ */
117
+ export function brittleChecks(code) {
118
+ return code
119
+ .split('\n')
120
+ .filter((line) => /expect|assert|should|Assert/.test(line) && /(['"`])[^'"`\n]*\b\d{3,}\b[^'"`\n]*\1/.test(line) && !/timeout|status|toBeLessThan|toBeGreaterThan/i.test(line))
121
+ .map((line) => line.trim().slice(0, 140));
122
+ }
123
+ /** Lines that check something: an assertion, or an expected value in a table of cases. */
124
+ const checkLines = (code) => code.split('\n').filter((line) => /expect|assert|should|Assert|verify/.test(line) && !/^\s*(?:\/\/|\*|#)/.test(line));
125
+ /** A check that can only say something is absent: it passes as long as the thing never appears. */
126
+ const NEGATIVE = /\.not[.\s(]|not\.to|toBeNull|toBeUndefined|toBeFalsy|assertFalse|assertNull|toHaveLength\(0\)|isNull|isFalse|\bnot_/;
127
+ /** A check that is true of almost any value: it proves the code ran, not that it is right. */
128
+ const SHALLOW = /toBeDefined\(\)|toBeTruthy\(\)|not\.toBeNull\(\)|toBeInstanceOf\(|typeof\s|assertIsNotNone|is not None|toBeGreaterThanOrEqual\(0\)/;
129
+ /**
130
+ * Checks worth a second look, though the test passes and fails when broken: a test whose every
131
+ * check only says something is absent, or only that a value exists, can keep passing while the
132
+ * behaviour it is named for disappears. The deliberate break cannot see this, because it changes
133
+ * one check and that check does fail.
134
+ */
135
+ export function weakChecks(code) {
136
+ const lines = checkLines(code);
137
+ if (lines.length === 0)
138
+ return null;
139
+ if (lines.every((l) => NEGATIVE.test(l)))
140
+ return `every check only says something is absent (${lines.length} check(s)), so it passes while the feature is missing`;
141
+ if (lines.every((l) => SHALLOW.test(l)))
142
+ return `every check only says a value exists (${lines.length} check(s)), not that it is right`;
143
+ return null;
144
+ }
@@ -0,0 +1,174 @@
1
+ import { deviceEnvSignal } from './device-errors.js';
2
+ import { isElementNotFound, networkSignal, valueMismatch } from './error-parse.js';
3
+ import { locatorDrift, locatorDriftStrict } from './drift-rules.js';
4
+ import { timingFlaky, timingIntercepted, timingIntermittent, timingKnownFlaky, timingRetryPass, timingStaleElement } from './timing-rules.js';
5
+ import { errorScenarioOf } from './llm/context.js';
6
+ import { errorShape, truncate } from './signature.js';
7
+ export const MASS_MIN_TESTS = 3;
8
+ export const MASS_MIN_SHARE = 0.3;
9
+ export { FLAKY_MAX_RATE, FLAKY_MIN_RATE, TIMING_MIN_RUNS } from './timing-rules.js';
10
+ export { DRIFT_MIN_SIMILARITY } from './drift-rules.js';
11
+ function declined(source, message, data) {
12
+ return { category: null, confidence: 0, evidence: [{ source, message, data }] };
13
+ }
14
+ /**
15
+ * Many tests in one run failing with the same error shape → one shared cause outside the
16
+ * tests. Many failures with *different* errors (e.g. a bad deploy) are not enough, and
17
+ * neither are many "element not found" timeouts: those share a shape only because the call
18
+ * log (with each test's own locator) is left out, and they point to a UI change or a broken
19
+ * page rather than to the environment (found in the drift lab, rules-3).
20
+ */
21
+ const envMassFailure = {
22
+ id: 'env-mass-failure',
23
+ evaluate({ record, run }) {
24
+ if (isElementNotFound(record.errorMessage)) {
25
+ const alike = run.failures.filter((f) => isElementNotFound(f.errorMessage)).length;
26
+ if (alike >= MASS_MIN_TESTS)
27
+ return declined(this.id, `${alike} tests in this run could not find their elements: a UI change or a broken page, not by itself an environment signal`);
28
+ return null;
29
+ }
30
+ const shape = errorShape(record.errorMessage);
31
+ const sameShape = new Set(run.failures.filter((f) => !isElementNotFound(f.errorMessage) && errorShape(f.errorMessage) === shape).map((f) => f.testId));
32
+ const failing = new Set(run.failures.map((f) => f.testId));
33
+ const attempted = Math.max(run.testsAttempted, failing.size, 1);
34
+ const share = sameShape.size / attempted;
35
+ if (sameShape.size >= MASS_MIN_TESTS && share >= MASS_MIN_SHARE) {
36
+ return {
37
+ category: 'ENV',
38
+ confidence: Math.min(0.95, 0.6 + 0.35 * share),
39
+ evidence: [
40
+ {
41
+ source: this.id,
42
+ message: `${sameShape.size} of ${attempted} tests in this run failed with the same error: "${truncate(shape, 80)}"`,
43
+ data: { tests: [...sameShape], share },
44
+ },
45
+ ],
46
+ };
47
+ }
48
+ if (failing.size >= MASS_MIN_TESTS && failing.size / attempted >= MASS_MIN_SHARE) {
49
+ const shapes = new Set(run.failures.map((f) => errorShape(f.errorMessage))).size;
50
+ return declined(this.id, `${failing.size} of ${attempted} tests failed in this run, but with ${shapes} different errors, not one shared cause`);
51
+ }
52
+ return null;
53
+ },
54
+ };
55
+ /**
56
+ * A mobile run that failed because of the device, not the app: no emulator or simulator, the app
57
+ * never installed or crashed on launch, the automation server unreachable, the test sandbox not
58
+ * buildable. The same kind of answer as a refused connection, and as safe to act on: re-run it
59
+ * somewhere healthy. Detox reports a crash and a test timeout with the same error, so the reader
60
+ * declines the timeout case and this rule never sees it.
61
+ */
62
+ const envDevice = {
63
+ id: 'env-device',
64
+ evaluate({ record }) {
65
+ const signal = deviceEnvSignal(record.errorMessage);
66
+ if (!signal)
67
+ return null;
68
+ const what = { 'no-device': 'no device or simulator was available', 'app-crashed': 'the app under test was not running', 'install-failed': 'the app or test runner could not be installed', 'driver-unreachable': 'the automation server could not be reached', sandbox: 'the test sandbox could not be built' }[signal.kind];
69
+ return {
70
+ category: 'ENV',
71
+ confidence: signal.confidence,
72
+ evidence: [{ source: this.id, message: `${what}: "${truncate(signal.match, 80)}"`, data: { ...signal } }],
73
+ };
74
+ },
75
+ };
76
+ const envNetwork = {
77
+ id: 'env-network',
78
+ evaluate({ record }) {
79
+ const signal = networkSignal(record.errorMessage);
80
+ if (!signal)
81
+ return null;
82
+ return {
83
+ category: 'ENV',
84
+ confidence: signal.confidence,
85
+ evidence: [{ source: this.id, message: `error shows a ${signal.kind} problem: "${truncate(signal.match, 60)}"`, data: { ...signal } }],
86
+ };
87
+ },
88
+ };
89
+ const DATA_COLLISION = /duplicate key value violates unique constraint|UNIQUE constraint failed|already exists/i;
90
+ /**
91
+ * Tests colliding on shared data (two tests creating the same record, leftovers from another
92
+ * test). Checked before the retry rule: a retry passing only means the collision did not
93
+ * happen again. Found on Supabase CI (rules-4).
94
+ */
95
+ const testDataCollision = {
96
+ id: 'test-data-collision',
97
+ evaluate({ record }) {
98
+ const match = record.errorMessage.match(DATA_COLLISION)?.[0];
99
+ if (!match)
100
+ return null;
101
+ return {
102
+ category: 'TEST_DATA',
103
+ confidence: 0.8,
104
+ evidence: [{ source: this.id, message: `the test's data collided with data that already existed: "${truncate(match, 60)}"`, data: { match } }],
105
+ };
106
+ },
107
+ };
108
+ /**
109
+ * The app's backend answered a server error (5xx) during the test. Checked before the retry
110
+ * rule, which used to call these races. Declines when the test provokes errors on purpose, and
111
+ * when the error is specific to this commit (possibly a backend bug the change introduced).
112
+ */
113
+ const envServerError = {
114
+ id: 'env-server-error',
115
+ evaluate({ record, history, loadFailedRequests }) {
116
+ const failure = (loadFailedRequests?.() ?? []).find((r) => r.status !== null && r.status >= 500);
117
+ if (!failure)
118
+ return null;
119
+ const what = `${failure.url} returned ${failure.status} during the test`;
120
+ if (errorScenarioOf(record.testId))
121
+ return declined(this.id, `${what}, but the test's name says it exercises error handling`);
122
+ if (history.sameCommitAttempts > 0 && history.sameCommitFailures === history.sameCommitAttempts && history.passesOnOtherCommits > 0) {
123
+ return declined(this.id, `${what}, but only on this commit while other commits pass: possibly a backend bug the change introduced`);
124
+ }
125
+ // A test that has never passed anywhere (often a new test for new code) gives no baseline: its
126
+ // server error is as likely a bug in the new backend code as an environment problem. Found as
127
+ // the one real bug the merge gate would have let through on the labelled data (rules-8).
128
+ if (history.runs > 0 && history.failures >= history.runs) {
129
+ return declined(this.id, `${what}, but this test has never passed (${history.failures} failures in ${history.runs} attempts), so the error may be a bug in new code`);
130
+ }
131
+ return { category: 'ENV', confidence: 0.7, evidence: [{ source: this.id, message: what, data: { ...failure } }] };
132
+ },
133
+ };
134
+ /**
135
+ * A value mismatch on a test that was reliable until it started failing the same way.
136
+ * Declines when the server failed during the test: the wrong value may come from the
137
+ * backend (ENV) or from a test that provokes errors on purpose, which only the LLM layer,
138
+ * seeing the whole trace, can tell apart (rules-3).
139
+ */
140
+ const realBugAssertion = {
141
+ id: 'real-bug-assertion',
142
+ evaluate({ record, history, loadFailedRequests }) {
143
+ const mismatch = valueMismatch(record.errorMessage);
144
+ if (!mismatch)
145
+ return null;
146
+ const { runs, failingStreak, streakDistinctErrors, failuresBeforeStreak, passesBeforeStreak } = history;
147
+ const values = `expected ${truncate(mismatch.expected, 30)}, received ${truncate(mismatch.received, 30)}`;
148
+ if (runs < 2)
149
+ return declined(this.id, `value mismatch (${values}) on the test's first recorded run; no history to call it stable`);
150
+ if (failingStreak === 0 || failuresBeforeStreak > 0) {
151
+ return declined(this.id, `value mismatch (${values}), but the test has passed and failed on and off, so its history isn't stable`);
152
+ }
153
+ if (streakDistinctErrors > 1) {
154
+ return declined(this.id, `value mismatch, but the error changed across the last ${failingStreak} failing runs (${streakDistinctErrors} variants)`);
155
+ }
156
+ const serverFailure = (loadFailedRequests?.() ?? []).find((r) => r.status !== null && r.status >= 500);
157
+ if (serverFailure) {
158
+ return declined(this.id, `value mismatch (${values}), but ${serverFailure.url} returned ${serverFailure.status} during the test: the backend may explain the wrong value`);
159
+ }
160
+ const message = passesBeforeStreak > 0
161
+ ? `passed ${passesBeforeStreak} time(s), then failed the last ${failingStreak} run(s) with the same mismatch: ${values}`
162
+ : `failed all ${failingStreak} recorded runs with the same mismatch: ${values}`;
163
+ return {
164
+ category: 'REAL_BUG',
165
+ confidence: Math.min(0.9, 0.65 + 0.05 * failingStreak + (passesBeforeStreak >= 3 ? 0.05 : 0)),
166
+ evidence: [{ source: this.id, message, data: { ...mismatch, failingStreak, passesBeforeStreak } }],
167
+ };
168
+ },
169
+ };
170
+ /**
171
+ * Priority order: the first matching rule decides. Signals from outside the test (ENV)
172
+ * come first, so a 5xx that also fails an assertion is ENV, not REAL_BUG.
173
+ */
174
+ export const RULES = [envMassFailure, envNetwork, envDevice, testDataCollision, envServerError, locatorDriftStrict, timingIntercepted, timingStaleElement, timingRetryPass, locatorDrift, timingFlaky, timingIntermittent, timingKnownFlaky, realBugAssertion];
@@ -0,0 +1,23 @@
1
+ // Error-text normalisation shared by the history table and the rule engine.
2
+ /**
3
+ * Error identity used for "same error every time?". Playwright appends a call log whose
4
+ * details (e.g. "9 × locator resolved to ...") change between identical failures, so
5
+ * only the text before "Call log:" is compared.
6
+ */
7
+ export function errorSignature(message) {
8
+ return message.split(/\n\s*Call log:/)[0].replace(/\s+/g, ' ').trim();
9
+ }
10
+ /**
11
+ * Coarser identity used to spot one cause hitting many tests: numbers, URL paths and
12
+ * long ids are blanked, so "ECONNREFUSED at http://host/cart" and ".../checkout" compare
13
+ * equal. Quoted strings are kept, so unrelated assertion failures stay apart.
14
+ */
15
+ export function errorShape(message) {
16
+ return errorSignature(message)
17
+ .replace(/\b(https?:\/\/[^/\s'"]+)[^\s'"]*/g, '$1')
18
+ .replace(/\b[0-9a-f]{8,}\b/gi, '<id>')
19
+ .replace(/\d+(?:\.\d+)?/g, '<n>');
20
+ }
21
+ export function truncate(text, max) {
22
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
23
+ }