@letsrunit/executor 0.20.0 → 0.21.1

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/README.md CHANGED
@@ -42,6 +42,14 @@ Executes a given Gherkin feature on a target URL with full reporting.
42
42
 
43
43
  Uses AI to refine and improve a user-provided test suggestion.
44
44
 
45
+ ### `explain(options)`
46
+
47
+ Imported from the dedicated subpath so runtime consumers that do not need local sqlite storage do not pull it into their main bundle.
48
+
49
+ ```ts
50
+ import explain from '@letsrunit/executor/explain';
51
+ ```
52
+
45
53
  ## Testing
46
54
 
47
55
  Run tests for this package:
@@ -0,0 +1,38 @@
1
+ import { createRequire } from 'module';
2
+
3
+ const require = createRequire(import.meta.url); const __filename = new URL(import.meta.url).pathname; __filename.substring(0, __filename.lastIndexOf('/'));
4
+ var __create = Object.create;
5
+ var __defProp = Object.defineProperty;
6
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
7
+ var __getOwnPropNames = Object.getOwnPropertyNames;
8
+ var __getProtoOf = Object.getPrototypeOf;
9
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
10
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
11
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
12
+ }) : x)(function(x) {
13
+ if (typeof require !== "undefined") return require.apply(this, arguments);
14
+ throw Error('Dynamic require of "' + x + '" is not supported');
15
+ });
16
+ var __commonJS = (cb, mod) => function __require2() {
17
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
18
+ };
19
+ var __copyProps = (to, from, except, desc) => {
20
+ if (from && typeof from === "object" || typeof from === "function") {
21
+ for (let key of __getOwnPropNames(from))
22
+ if (!__hasOwnProp.call(to, key) && key !== except)
23
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
24
+ }
25
+ return to;
26
+ };
27
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
28
+ // If the importer is in node compatibility mode or this is not an ESM
29
+ // file that has been converted to a CommonJS file using a Babel-
30
+ // compatible transform (i.e. "__esModule" has not been set), then set
31
+ // "default" to the CommonJS "module.exports" for node compatibility.
32
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
33
+ mod
34
+ ));
35
+
36
+ export { __commonJS, __require, __toESM };
37
+ //# sourceMappingURL=chunk-PMQZSUZG.js.map
38
+ //# sourceMappingURL=chunk-PMQZSUZG.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-PMQZSUZG.js"}
@@ -0,0 +1,30 @@
1
+ interface ExplainOptions {
2
+ dbPath?: string;
3
+ artifactsDir?: string;
4
+ }
5
+ interface ExplainScenario {
6
+ scenarioId: string;
7
+ scenarioName: string;
8
+ featurePath: string;
9
+ steps: string;
10
+ failureMessage: string;
11
+ update: 'test' | 'code';
12
+ updateMessage: string;
13
+ reason: string;
14
+ advice: string;
15
+ }
16
+ interface ExplainScenarioError {
17
+ scenarioId: string;
18
+ scenarioName: string;
19
+ featurePath: string;
20
+ error: string;
21
+ }
22
+ interface ExplainResult {
23
+ hasRun: boolean;
24
+ totalFailed: number;
25
+ explanations: ExplainScenario[];
26
+ errors: ExplainScenarioError[];
27
+ }
28
+ declare function explain(options?: ExplainOptions): Promise<ExplainResult>;
29
+
30
+ export { type ExplainOptions, type ExplainResult, type ExplainScenario, type ExplainScenarioError, explain as default };
@@ -0,0 +1,181 @@
1
+ import { createRequire } from 'module';
2
+ import './chunk-PMQZSUZG.js';
3
+ import { executableScenarioIds } from '@letsrunit/gherkin';
4
+ import { unifiedHtmlDiff } from '@letsrunit/playwright';
5
+ import { openStore, findLastRun, findLastTest, findArtifacts } from '@letsrunit/store';
6
+ import { statusSymbol } from '@letsrunit/utils';
7
+ import { readFileSync } from 'fs';
8
+ import { join, dirname, isAbsolute } from 'path';
9
+ import { generate } from '@letsrunit/ai';
10
+ import { z } from 'zod';
11
+
12
+ createRequire(import.meta.url); const __filename = new URL(import.meta.url).pathname; __filename.substring(0, __filename.lastIndexOf('/'));
13
+ var PROMPT = `You are analyzing a failed browser test to provide useful information to the user
14
+
15
+ Classify the failure as:
16
+ - "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.
17
+ - "code" when the failed step is caused by broken or missing functionality, or when the user can no longer complete the expected flow.
18
+
19
+ The advise should match the classification. If the classification is to update the test, don't advise to update the code and visa-versa.
20
+ The advise must be a single concrete action to solve the failure without examples or alternatives.
21
+ `;
22
+ var ResponseSchema = z.object({
23
+ update: z.enum(["test", "code"]).describe('Classification: "test" if the test is outdated OR "code" if it is likely a regression'),
24
+ reason: z.string().describe("One short sentence describing only the observed user-visible change"),
25
+ advice: z.string().describe("One short imperative sentence with a single action")
26
+ });
27
+ async function explainFailure(input) {
28
+ return await generate(PROMPT, input, { model: "medium", schema: ResponseSchema });
29
+ }
30
+
31
+ // src/explain.ts
32
+ function defaultDbPath() {
33
+ return join(process.cwd(), ".letsrunit", "letsrunit.db");
34
+ }
35
+ function resolveArtifactsDir(dbPath, artifactsDir) {
36
+ if (artifactsDir) return artifactsDir;
37
+ return join(dirname(dbPath), "artifacts");
38
+ }
39
+ function resolveFeaturePath(path) {
40
+ return isAbsolute(path) ? path : join(process.cwd(), path);
41
+ }
42
+ function isFailed(test) {
43
+ return test.failedStepIndex !== null || test.status === "failed";
44
+ }
45
+ function colorize(status, text) {
46
+ switch (status) {
47
+ case "success":
48
+ case "skipped":
49
+ return `\x1B[90m${text}\x1B[0m`;
50
+ case "failure":
51
+ return `\x1B[31m${text}\x1B[0m`;
52
+ default:
53
+ return text;
54
+ }
55
+ }
56
+ function renderScenarioSteps(test, failedStepIndex) {
57
+ return test.steps.map((step) => {
58
+ const status = step.index < failedStepIndex ? "success" : step.index === failedStepIndex ? "failure" : "skipped";
59
+ const symbol = statusSymbol(status);
60
+ return colorize(status, `${symbol} ${step.text}`);
61
+ }).join("\n");
62
+ }
63
+ function pickLatestHtmlFilename(filenames) {
64
+ const html = filenames.filter((artifact) => artifact.filename.endsWith(".html"));
65
+ if (html.length === 0) return null;
66
+ return html.at(-1)?.filename ?? null;
67
+ }
68
+ function asHeading(update) {
69
+ return update === "test" ? "Test outdated" : "Possible regression";
70
+ }
71
+ function buildPrompt(test, renderedSteps, failureMessage, diff) {
72
+ return [
73
+ `Feature: ${test.featureName}`,
74
+ `Scenario: ${test.scenarioName}`,
75
+ "",
76
+ renderedSteps,
77
+ "",
78
+ "Failure message:",
79
+ failureMessage,
80
+ "",
81
+ "HTML diff (baseline -> failed):",
82
+ diff
83
+ ].join("\n");
84
+ }
85
+ function parseFeatureIds(featureCache, featurePath) {
86
+ const cached = featureCache.get(featurePath);
87
+ if (cached) return cached;
88
+ const source = readFileSync(resolveFeaturePath(featurePath), "utf-8");
89
+ const ids = executableScenarioIds(source, featurePath);
90
+ featureCache.set(featurePath, ids);
91
+ return ids;
92
+ }
93
+ function createError(test, error) {
94
+ return {
95
+ scenarioId: test.scenarioId,
96
+ scenarioName: test.scenarioName,
97
+ featurePath: test.featurePath,
98
+ error
99
+ };
100
+ }
101
+ async function explain(options = {}) {
102
+ const dbPath = options.dbPath ?? defaultDbPath();
103
+ const artifactsDir = resolveArtifactsDir(dbPath, options.artifactsDir);
104
+ const db = openStore(dbPath);
105
+ try {
106
+ const run = findLastRun(db);
107
+ if (!run) {
108
+ return { hasRun: false, totalFailed: 0, explanations: [], errors: [] };
109
+ }
110
+ const featureCache = /* @__PURE__ */ new Map();
111
+ const failedTests = run.tests.filter((test) => isFailed(test));
112
+ const explanations = [];
113
+ const errors = [];
114
+ for (const test of failedTests) {
115
+ try {
116
+ if (test.failedStepIndex === null) {
117
+ errors.push(createError(test, "Missing failed step index for failed scenario"));
118
+ continue;
119
+ }
120
+ const knownScenarioIds = parseFeatureIds(featureCache, test.featurePath);
121
+ if (!knownScenarioIds.has(test.scenarioId)) {
122
+ errors.push(createError(test, "Scenario has changed in feature file since the last run"));
123
+ continue;
124
+ }
125
+ const failedStep = test.steps.find((step) => step.index === test.failedStepIndex);
126
+ if (!failedStep) {
127
+ errors.push(createError(test, `Failed step at index ${test.failedStepIndex} was not found`));
128
+ continue;
129
+ }
130
+ const baseline = findLastTest(db, test.scenarioId, "passed");
131
+ if (!baseline) {
132
+ errors.push(createError(test, "No prior successful baseline found for this scenario"));
133
+ continue;
134
+ }
135
+ const failedArtifacts = findArtifacts(db, test.id, failedStep.id);
136
+ const baselineArtifacts = findArtifacts(db, baseline.id, failedStep.id);
137
+ const failedHtmlFile = pickLatestHtmlFilename(failedArtifacts);
138
+ const baselineHtmlFile = pickLatestHtmlFilename(baselineArtifacts);
139
+ if (!failedHtmlFile || !baselineHtmlFile) {
140
+ errors.push(createError(test, "Missing HTML snapshot artifact for failed step"));
141
+ continue;
142
+ }
143
+ const failedHtml = readFileSync(join(artifactsDir, failedHtmlFile), "utf-8");
144
+ const baselineHtml = readFileSync(join(artifactsDir, baselineHtmlFile), "utf-8");
145
+ const diff = await unifiedHtmlDiff(
146
+ { html: baselineHtml, url: "about:blank" },
147
+ { html: failedHtml, url: "about:blank" }
148
+ );
149
+ const renderedSteps = renderScenarioSteps(test, test.failedStepIndex);
150
+ const failureMessage = test.error ?? "Step failed";
151
+ const prompt = buildPrompt(test, renderedSteps, failureMessage, diff);
152
+ const ai = await explainFailure(prompt);
153
+ explanations.push({
154
+ scenarioId: test.scenarioId,
155
+ scenarioName: test.scenarioName,
156
+ featurePath: test.featurePath,
157
+ steps: renderedSteps,
158
+ failureMessage,
159
+ update: ai.update,
160
+ updateMessage: asHeading(ai.update),
161
+ reason: ai.reason,
162
+ advice: ai.advice
163
+ });
164
+ } catch (error) {
165
+ errors.push(createError(test, error.message));
166
+ }
167
+ }
168
+ return {
169
+ hasRun: true,
170
+ totalFailed: failedTests.length,
171
+ explanations,
172
+ errors
173
+ };
174
+ } finally {
175
+ db.close();
176
+ }
177
+ }
178
+
179
+ export { explain as default };
180
+ //# sourceMappingURL=explain.js.map
181
+ //# sourceMappingURL=explain.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/ai/explain-failure.ts","../src/explain.ts"],"names":[],"mappings":"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","file":"explain.js","sourcesContent":["import { generate } from '@letsrunit/ai';\nimport { z } from 'zod';\n\nconst PROMPT = `You are analyzing a failed browser test to provide useful information to the user\n\nClassify the failure as:\n- \"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.\n- \"code\" when the failed step is caused by broken or missing functionality, or when the user can no longer complete the expected flow.\n\nThe advise should match the classification. If the classification is to update the test, don't advise to update the code and visa-versa.\nThe advise must be a single concrete action to solve the failure without examples or alternatives.\n`;\n\nconst ResponseSchema = z.object({\n update: z\n .enum(['test', 'code'])\n .describe('Classification: \"test\" if the test is outdated OR \"code\" if it is likely a regression'),\n reason: z\n .string()\n .describe('One short sentence describing only the observed user-visible change'),\n advice: z.string().describe('One short imperative sentence with a single action'),\n});\n\nexport type ExplainFailureResponse = z.infer<typeof ResponseSchema>;\n\nexport async function explainFailure(input: string): Promise<ExplainFailureResponse> {\n return await generate(PROMPT, input, { model: 'medium', schema: ResponseSchema });\n}\n","import { executableScenarioIds } from '@letsrunit/gherkin';\nimport { unifiedHtmlDiff } from '@letsrunit/playwright';\nimport { findArtifacts, findLastRun, findLastTest, type LastRunTest, openStore } from '@letsrunit/store';\nimport { statusSymbol } from '@letsrunit/utils';\nimport { readFileSync } from 'node:fs';\nimport { dirname, isAbsolute, join } from 'node:path';\nimport { explainFailure, type ExplainFailureResponse } from './ai/explain-failure';\n\nexport interface ExplainOptions {\n dbPath?: string;\n artifactsDir?: string;\n}\n\nexport interface ExplainScenario {\n scenarioId: string;\n scenarioName: string;\n featurePath: string;\n steps: string;\n failureMessage: string;\n update: 'test' | 'code';\n updateMessage: string;\n reason: string;\n advice: string;\n}\n\nexport interface ExplainScenarioError {\n scenarioId: string;\n scenarioName: string;\n featurePath: string;\n error: string;\n}\n\nexport interface ExplainResult {\n hasRun: boolean;\n totalFailed: number;\n explanations: ExplainScenario[];\n errors: ExplainScenarioError[];\n}\n\nfunction defaultDbPath(): string {\n return join(process.cwd(), '.letsrunit', 'letsrunit.db');\n}\n\nfunction resolveArtifactsDir(dbPath: string, artifactsDir?: string): string {\n if (artifactsDir) return artifactsDir;\n return join(dirname(dbPath), 'artifacts');\n}\n\nfunction resolveFeaturePath(path: string): string {\n return isAbsolute(path) ? path : join(process.cwd(), path);\n}\n\nfunction isFailed(test: LastRunTest): boolean {\n return test.failedStepIndex !== null || test.status === 'failed';\n}\n\nfunction colorize(status: string, text: string): string {\n switch (status) {\n case 'success':\n case 'skipped':\n return `\\x1b[90m${text}\\x1b[0m`;\n case 'failure':\n return `\\x1b[31m${text}\\x1b[0m`;\n default:\n return text;\n }\n}\n\nfunction renderScenarioSteps(test: LastRunTest, failedStepIndex: number): string {\n return test.steps\n .map((step) => {\n const status = step.index < failedStepIndex ? 'success' : step.index === failedStepIndex ? 'failure' : 'skipped';\n const symbol = statusSymbol(status);\n return colorize(status, `${symbol} ${step.text}`);\n })\n .join('\\n');\n}\n\nfunction pickLatestHtmlFilename(filenames: Array<{ filename: string }>): string | null {\n const html = filenames.filter((artifact) => artifact.filename.endsWith('.html'));\n if (html.length === 0) return null;\n return html.at(-1)?.filename ?? null;\n}\n\nfunction asHeading(update: ExplainFailureResponse['update']): string {\n return update === 'test' ? 'Test outdated' : 'Possible regression';\n}\n\nfunction buildPrompt(test: LastRunTest, renderedSteps: string, failureMessage: string, diff: string): string {\n return [\n `Feature: ${test.featureName}`,\n `Scenario: ${test.scenarioName}`,\n '',\n renderedSteps,\n '',\n 'Failure message:',\n failureMessage,\n '',\n 'HTML diff (baseline -> failed):',\n diff,\n ].join('\\n');\n}\n\nfunction parseFeatureIds(featureCache: Map<string, Set<string>>, featurePath: string): Set<string> {\n const cached = featureCache.get(featurePath);\n if (cached) return cached;\n\n const source = readFileSync(resolveFeaturePath(featurePath), 'utf-8');\n const ids = executableScenarioIds(source, featurePath);\n featureCache.set(featurePath, ids);\n return ids;\n}\n\nfunction createError(test: LastRunTest, error: string): ExplainScenarioError {\n return {\n scenarioId: test.scenarioId,\n scenarioName: test.scenarioName,\n featurePath: test.featurePath,\n error,\n };\n}\n\nexport default async function explain(options: ExplainOptions = {}): Promise<ExplainResult> {\n const dbPath = options.dbPath ?? defaultDbPath();\n const artifactsDir = resolveArtifactsDir(dbPath, options.artifactsDir);\n const db = openStore(dbPath);\n\n try {\n const run = findLastRun(db);\n if (!run) {\n return { hasRun: false, totalFailed: 0, explanations: [], errors: [] };\n }\n\n const featureCache = new Map<string, Set<string>>();\n const failedTests = run.tests.filter((test) => isFailed(test));\n const explanations: ExplainScenario[] = [];\n const errors: ExplainScenarioError[] = [];\n\n for (const test of failedTests) {\n try {\n if (test.failedStepIndex === null) {\n errors.push(createError(test, 'Missing failed step index for failed scenario'));\n continue;\n }\n\n const knownScenarioIds = parseFeatureIds(featureCache, test.featurePath);\n if (!knownScenarioIds.has(test.scenarioId)) {\n errors.push(createError(test, 'Scenario has changed in feature file since the last run'));\n continue;\n }\n\n const failedStep = test.steps.find((step) => step.index === test.failedStepIndex);\n if (!failedStep) {\n errors.push(createError(test, `Failed step at index ${test.failedStepIndex} was not found`));\n continue;\n }\n\n const baseline = findLastTest(db, test.scenarioId, 'passed');\n if (!baseline) {\n errors.push(createError(test, 'No prior successful baseline found for this scenario'));\n continue;\n }\n\n const failedArtifacts = findArtifacts(db, test.id, failedStep.id);\n const baselineArtifacts = findArtifacts(db, baseline.id, failedStep.id);\n\n const failedHtmlFile = pickLatestHtmlFilename(failedArtifacts);\n const baselineHtmlFile = pickLatestHtmlFilename(baselineArtifacts);\n\n if (!failedHtmlFile || !baselineHtmlFile) {\n errors.push(createError(test, 'Missing HTML snapshot artifact for failed step'));\n continue;\n }\n\n const failedHtml = readFileSync(join(artifactsDir, failedHtmlFile), 'utf-8');\n const baselineHtml = readFileSync(join(artifactsDir, baselineHtmlFile), 'utf-8');\n\n const diff = await unifiedHtmlDiff(\n { html: baselineHtml, url: 'about:blank' },\n { html: failedHtml, url: 'about:blank' },\n );\n\n const renderedSteps = renderScenarioSteps(test, test.failedStepIndex);\n const failureMessage = test.error ?? 'Step failed';\n const prompt = buildPrompt(test, renderedSteps, failureMessage, diff);\n const ai = await explainFailure(prompt);\n\n explanations.push({\n scenarioId: test.scenarioId,\n scenarioName: test.scenarioName,\n featurePath: test.featurePath,\n steps: renderedSteps,\n failureMessage,\n update: ai.update,\n updateMessage: asHeading(ai.update),\n reason: ai.reason,\n advice: ai.advice,\n });\n } catch (error) {\n errors.push(createError(test, (error as Error).message));\n }\n }\n\n return {\n hasRun: true,\n totalFailed: failedTests.length,\n explanations,\n errors,\n };\n } finally {\n db.close();\n }\n}\n"]}
package/dist/index.d.ts CHANGED
@@ -6,35 +6,6 @@ import { RequiredAndOptional } from '@letsrunit/utils';
6
6
 
7
7
  declare function refineSuggestion(suggestion: string | Pick<Feature, 'name' | 'description' | 'comments'>): Promise<Required<Pick<Feature, 'name' | 'description' | 'comments'>>>;
8
8
 
9
- interface ExplainOptions {
10
- dbPath?: string;
11
- artifactsDir?: string;
12
- }
13
- interface ExplainScenario {
14
- scenarioId: string;
15
- scenarioName: string;
16
- featurePath: string;
17
- steps: string;
18
- failureMessage: string;
19
- update: 'test' | 'code';
20
- updateMessage: string;
21
- reason: string;
22
- advice: string;
23
- }
24
- interface ExplainScenarioError {
25
- scenarioId: string;
26
- scenarioName: string;
27
- featurePath: string;
28
- error: string;
29
- }
30
- interface ExplainResult {
31
- hasRun: boolean;
32
- totalFailed: number;
33
- explanations: ExplainScenario[];
34
- errors: ExplainScenarioError[];
35
- }
36
- declare function explain(options?: ExplainOptions): Promise<ExplainResult>;
37
-
38
9
  declare const ActionSchema: z.ZodObject<{
39
10
  name: z.ZodString;
40
11
  description: z.ZodString;
@@ -92,4 +63,4 @@ interface GenerateOptions {
92
63
  }
93
64
  declare function run(target: string, feature: Feature | string, opts?: GenerateOptions): Promise<Result>;
94
65
 
95
- export { type PreparedAction as Action, ActionSchema, type AppInfo, type Assessment, AssessmentSchema, type ExplainResult, type Result, explain, explore, generate, refineSuggestion, run };
66
+ export { type PreparedAction as Action, ActionSchema, type AppInfo, type Assessment, AssessmentSchema, type Result, explore, generate, refineSuggestion, run };