@letsrunit/executor 0.10.0 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letsrunit/executor",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "High-level test execution orchestrator for letsrunit",
5
5
  "keywords": [
6
6
  "testing",
@@ -41,13 +41,15 @@
41
41
  },
42
42
  "packageManager": "yarn@4.10.3",
43
43
  "dependencies": {
44
- "@letsrunit/ai": "0.10.0",
45
- "@letsrunit/controller": "0.10.0",
46
- "@letsrunit/gherkin": "0.10.0",
47
- "@letsrunit/journal": "0.10.0",
48
- "@letsrunit/mailbox": "0.10.0",
49
- "@letsrunit/playwright": "0.10.0",
50
- "@letsrunit/utils": "0.10.0"
44
+ "@letsrunit/ai": "0.11.0",
45
+ "@letsrunit/controller": "0.11.0",
46
+ "@letsrunit/gherkin": "0.11.0",
47
+ "@letsrunit/journal": "0.11.0",
48
+ "@letsrunit/mailbox": "0.11.0",
49
+ "@letsrunit/playwright": "0.11.0",
50
+ "@letsrunit/store": "0.11.0",
51
+ "@letsrunit/utils": "0.11.0",
52
+ "zod": "^4.3.5"
51
53
  },
52
54
  "devDependencies": {
53
55
  "@types/node": "^25.0.9",
@@ -1,8 +1,7 @@
1
1
  import { generate } from '@letsrunit/ai';
2
2
  import type { PageInfo } from '@letsrunit/playwright';
3
+ import { extractPageInfo, scrubHtml } from '@letsrunit/playwright';
3
4
  import { stringify as toYaml } from 'yaml';
4
- import { extractPageInfo } from '../../../playwright/src/page-info';
5
- import { scrubHtml } from '../../../playwright/src/scrub-html';
6
5
 
7
6
  const PROMPT = `Convert raw HTML into compact Markdown using a limited set of custom blocks. The output feeds must be consistent.
8
7
 
@@ -1,6 +1,6 @@
1
1
  import { generate } from '@letsrunit/ai';
2
2
  import { Journal } from '@letsrunit/journal';
3
- import { unifiedHtmlDiff } from '../../../playwright/src/unified-html-diff';
3
+ import { unifiedHtmlDiff } from '@letsrunit/playwright';
4
4
  import { locatorRules } from './locator-rules';
5
5
 
6
6
  const PROMPT = `You analyze a diff of two HTML files. Your task is to detect the most significant user-visible changes and output 0 to 3 Playwright Gherkin steps using only these step definitions:
@@ -0,0 +1,28 @@
1
+ import { generate } from '@letsrunit/ai';
2
+ import { z } from 'zod';
3
+
4
+ const PROMPT = `You are analyzing a failed browser test to provide useful information to the user
5
+
6
+ Classify the failure as:
7
+ - "test" when the failed step is caused by an intentional user-visible change in the application, and the product still appears to work as expected.
8
+ - "code" when the failed step is caused by broken or missing functionality, or when the user can no longer complete the expected flow.
9
+
10
+ The advise should match the classification. If the classification is to update the test, don't advise to update the code and visa-versa.
11
+ The advise must be a single concrete action to solve the failure without examples or alternatives.
12
+ `;
13
+
14
+ const ResponseSchema = z.object({
15
+ update: z
16
+ .enum(['test', 'code'])
17
+ .describe('Classification: "test" if the test is outdated OR "code" if it is likely a regression'),
18
+ reason: z
19
+ .string()
20
+ .describe('One short sentence describing only the observed user-visible change'),
21
+ advice: z.string().describe('One short imperative sentence with a single action'),
22
+ });
23
+
24
+ export type ExplainFailureResponse = z.infer<typeof ResponseSchema>;
25
+
26
+ export async function explainFailure(input: string): Promise<ExplainFailureResponse> {
27
+ return await generate(PROMPT, input, { model: 'medium', schema: ResponseSchema });
28
+ }
package/src/explain.ts ADDED
@@ -0,0 +1,213 @@
1
+ import { executableScenarioIds } from '@letsrunit/gherkin';
2
+ import { unifiedHtmlDiff } from '@letsrunit/playwright';
3
+ import { findArtifacts, findLastRun, findLastTest, type LastRunTest, openStore } from '@letsrunit/store';
4
+ import { statusSymbol } from '@letsrunit/utils';
5
+ import { readFileSync } from 'node:fs';
6
+ import { dirname, isAbsolute, join } from 'node:path';
7
+ import { explainFailure, type ExplainFailureResponse } from './ai/explain-failure';
8
+
9
+ export interface ExplainOptions {
10
+ dbPath?: string;
11
+ artifactsDir?: string;
12
+ }
13
+
14
+ export interface ExplainScenario {
15
+ scenarioId: string;
16
+ scenarioName: string;
17
+ featurePath: string;
18
+ steps: string;
19
+ failureMessage: string;
20
+ update: 'test' | 'code';
21
+ updateMessage: string;
22
+ reason: string;
23
+ advice: string;
24
+ }
25
+
26
+ export interface ExplainScenarioError {
27
+ scenarioId: string;
28
+ scenarioName: string;
29
+ featurePath: string;
30
+ error: string;
31
+ }
32
+
33
+ export interface ExplainResult {
34
+ hasRun: boolean;
35
+ totalFailed: number;
36
+ explanations: ExplainScenario[];
37
+ errors: ExplainScenarioError[];
38
+ }
39
+
40
+ function defaultDbPath(): string {
41
+ return join(process.cwd(), '.letsrunit', 'letsrunit.db');
42
+ }
43
+
44
+ function resolveArtifactsDir(dbPath: string, artifactsDir?: string): string {
45
+ if (artifactsDir) return artifactsDir;
46
+ return join(dirname(dbPath), 'artifacts');
47
+ }
48
+
49
+ function resolveFeaturePath(path: string): string {
50
+ return isAbsolute(path) ? path : join(process.cwd(), path);
51
+ }
52
+
53
+ function isFailed(test: LastRunTest): boolean {
54
+ return test.failedStepIndex !== null || test.status === 'failed';
55
+ }
56
+
57
+ function colorize(status: string, text: string): string {
58
+ switch (status) {
59
+ case 'success':
60
+ case 'skipped':
61
+ return `\x1b[90m${text}\x1b[0m`;
62
+ case 'failure':
63
+ return `\x1b[31m${text}\x1b[0m`;
64
+ default:
65
+ return text;
66
+ }
67
+ }
68
+
69
+ function renderScenarioSteps(test: LastRunTest, failedStepIndex: number): string {
70
+ return test.steps
71
+ .map((step) => {
72
+ const status = step.index < failedStepIndex ? 'success' : step.index === failedStepIndex ? 'failure' : 'skipped';
73
+ const symbol = statusSymbol(status);
74
+ return colorize(status, `${symbol} ${step.text}`);
75
+ })
76
+ .join('\n');
77
+ }
78
+
79
+ function pickLatestHtmlFilename(filenames: Array<{ filename: string }>): string | null {
80
+ const html = filenames.filter((artifact) => artifact.filename.endsWith('.html'));
81
+ if (html.length === 0) return null;
82
+ return html.at(-1)?.filename ?? null;
83
+ }
84
+
85
+ function asHeading(update: ExplainFailureResponse['update']): string {
86
+ return update === 'test' ? 'Test outdated' : 'Possible regression';
87
+ }
88
+
89
+ function buildPrompt(test: LastRunTest, renderedSteps: string, failureMessage: string, diff: string): string {
90
+ return [
91
+ `Feature: ${test.featureName}`,
92
+ `Scenario: ${test.scenarioName}`,
93
+ '',
94
+ renderedSteps,
95
+ '',
96
+ 'Failure message:',
97
+ failureMessage,
98
+ '',
99
+ 'HTML diff (baseline -> failed):',
100
+ diff,
101
+ ].join('\n');
102
+ }
103
+
104
+ function parseFeatureIds(featureCache: Map<string, Set<string>>, featurePath: string): Set<string> {
105
+ const cached = featureCache.get(featurePath);
106
+ if (cached) return cached;
107
+
108
+ const source = readFileSync(resolveFeaturePath(featurePath), 'utf-8');
109
+ const ids = executableScenarioIds(source, featurePath);
110
+ featureCache.set(featurePath, ids);
111
+ return ids;
112
+ }
113
+
114
+ function createError(test: LastRunTest, error: string): ExplainScenarioError {
115
+ return {
116
+ scenarioId: test.scenarioId,
117
+ scenarioName: test.scenarioName,
118
+ featurePath: test.featurePath,
119
+ error,
120
+ };
121
+ }
122
+
123
+ export default async function explain(options: ExplainOptions = {}): Promise<ExplainResult> {
124
+ const dbPath = options.dbPath ?? defaultDbPath();
125
+ const artifactsDir = resolveArtifactsDir(dbPath, options.artifactsDir);
126
+ const db = openStore(dbPath);
127
+
128
+ try {
129
+ const run = findLastRun(db);
130
+ if (!run) {
131
+ return { hasRun: false, totalFailed: 0, explanations: [], errors: [] };
132
+ }
133
+
134
+ const featureCache = new Map<string, Set<string>>();
135
+ const failedTests = run.tests.filter((test) => isFailed(test));
136
+ const explanations: ExplainScenario[] = [];
137
+ const errors: ExplainScenarioError[] = [];
138
+
139
+ for (const test of failedTests) {
140
+ try {
141
+ if (test.failedStepIndex === null) {
142
+ errors.push(createError(test, 'Missing failed step index for failed scenario'));
143
+ continue;
144
+ }
145
+
146
+ const knownScenarioIds = parseFeatureIds(featureCache, test.featurePath);
147
+ if (!knownScenarioIds.has(test.scenarioId)) {
148
+ errors.push(createError(test, 'Scenario has changed in feature file since the last run'));
149
+ continue;
150
+ }
151
+
152
+ const failedStep = test.steps.find((step) => step.index === test.failedStepIndex);
153
+ if (!failedStep) {
154
+ errors.push(createError(test, `Failed step at index ${test.failedStepIndex} was not found`));
155
+ continue;
156
+ }
157
+
158
+ const baseline = findLastTest(db, test.scenarioId, 'passed');
159
+ if (!baseline) {
160
+ errors.push(createError(test, 'No prior successful baseline found for this scenario'));
161
+ continue;
162
+ }
163
+
164
+ const failedArtifacts = findArtifacts(db, test.id, failedStep.id);
165
+ const baselineArtifacts = findArtifacts(db, baseline.id, failedStep.id);
166
+
167
+ const failedHtmlFile = pickLatestHtmlFilename(failedArtifacts);
168
+ const baselineHtmlFile = pickLatestHtmlFilename(baselineArtifacts);
169
+
170
+ if (!failedHtmlFile || !baselineHtmlFile) {
171
+ errors.push(createError(test, 'Missing HTML snapshot artifact for failed step'));
172
+ continue;
173
+ }
174
+
175
+ const failedHtml = readFileSync(join(artifactsDir, failedHtmlFile), 'utf-8');
176
+ const baselineHtml = readFileSync(join(artifactsDir, baselineHtmlFile), 'utf-8');
177
+
178
+ const diff = await unifiedHtmlDiff(
179
+ { html: baselineHtml, url: 'about:blank' },
180
+ { html: failedHtml, url: 'about:blank' },
181
+ );
182
+
183
+ const renderedSteps = renderScenarioSteps(test, test.failedStepIndex);
184
+ const failureMessage = test.error ?? 'Step failed';
185
+ const prompt = buildPrompt(test, renderedSteps, failureMessage, diff);
186
+ const ai = await explainFailure(prompt);
187
+
188
+ explanations.push({
189
+ scenarioId: test.scenarioId,
190
+ scenarioName: test.scenarioName,
191
+ featurePath: test.featurePath,
192
+ steps: renderedSteps,
193
+ failureMessage,
194
+ update: ai.update,
195
+ updateMessage: asHeading(ai.update),
196
+ reason: ai.reason,
197
+ advice: ai.advice,
198
+ });
199
+ } catch (error) {
200
+ errors.push(createError(test, (error as Error).message));
201
+ }
202
+ }
203
+
204
+ return {
205
+ hasRun: true,
206
+ totalFailed: failedTests.length,
207
+ explanations,
208
+ errors,
209
+ };
210
+ } finally {
211
+ db.close();
212
+ }
213
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { refineSuggestion } from './ai/refine-suggestion';
2
+ export { default as explain, type ExplainResult } from './explain';
2
3
  export { default as explore, type PreparedAction as Action } from './explore';
3
4
  export { default as generate } from './generate';
4
5
  export { default as run } from './run';