@selfcure/generator 0.1.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/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # @selfcure/generator
2
+
3
+ > Legacy BYOK test generator for **selfcure** — turns component analysis into Playwright test code via an LLM.
4
+
5
+ Part of selfcure's **fallback pipeline** for teams not using the Playwright Test Agents. Sends component analysis to a provider-agnostic `LanguageModel` (via the Vercel AI SDK) and receives a Playwright `.spec.ts`. **BYOK** — bring your own key; no credentials ship with the package.
6
+
7
+ Supported providers: Anthropic, OpenAI, Google Gemini, Groq, DeepSeek, and local Ollama.
8
+
9
+ Internal library powering `selfcure run`. The headline, preventive flow is `selfcure lint` — see [`@selfcure/cli`](https://www.npmjs.com/package/@selfcure/cli).
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @selfcure/generator
15
+ ```
16
+
17
+ ## Docs
18
+
19
+ Full documentation: https://github.com/ricardofrancocustodio/selfcure#readme
@@ -0,0 +1,113 @@
1
+ import { AnalysisResult } from '@selfcure/analyzer';
2
+ import { LanguageModel } from 'ai';
3
+ import { ProjectMap } from '@selfcure/crawler';
4
+
5
+ type ProviderId = 'anthropic' | 'openai' | 'google' | 'groq' | 'deepseek' | 'ollama';
6
+ interface ProviderMeta {
7
+ id: ProviderId;
8
+ label: string;
9
+ /** Env var that holds the API key (null = no key required, e.g. Ollama) */
10
+ envVar: string | null;
11
+ defaultGenerationModel: string;
12
+ defaultHealingModel: string;
13
+ /** Default base URL — only present for providers behind a configurable endpoint */
14
+ defaultBaseURL?: string;
15
+ /** Short hint shown in the wizard */
16
+ hint: string;
17
+ apiKeyPlaceholder: string;
18
+ }
19
+ declare const PROVIDERS: Record<ProviderId, ProviderMeta>;
20
+ interface AIConfig {
21
+ provider: ProviderId;
22
+ generationModel?: string;
23
+ healingModel?: string;
24
+ /** Override the env var name (default: PROVIDERS[provider].envVar) */
25
+ apiKeyEnv?: string;
26
+ /** Override the base URL (used by Ollama / DeepSeek / self-hosted endpoints) */
27
+ baseURL?: string;
28
+ }
29
+ type ModelKind = 'generation' | 'healing';
30
+ /**
31
+ * Build a Vercel AI SDK LanguageModel for the given config + role.
32
+ * Throws if the provider requires an API key and the env var is missing.
33
+ */
34
+ declare function getModel(ai: AIConfig, kind: ModelKind): LanguageModel;
35
+
36
+ interface RuntimeDiscoveryResult {
37
+ routes: Array<{
38
+ route: string;
39
+ status: string;
40
+ interactiveElements: Array<{
41
+ score: number;
42
+ }>;
43
+ }>;
44
+ }
45
+ interface DiscoveryLlmInput {
46
+ framework: string;
47
+ packageManager: string;
48
+ packageScripts: string[];
49
+ routeCandidates: string[];
50
+ /** Present only when runtime discovery already ran */
51
+ runtimeFindings?: {
52
+ route: string;
53
+ status: string;
54
+ flaggedCount: number;
55
+ }[];
56
+ }
57
+ interface HiddenStateHint {
58
+ route: string;
59
+ /** Plain-language description of the UI trigger, e.g. "button 'Add to cart'" */
60
+ triggerHint: string;
61
+ }
62
+ interface DiscoveryLlmOutput {
63
+ routesToVisit: string[];
64
+ hiddenStatesToExplore: HiddenStateHint[];
65
+ /** LLM's own confidence in its suggestions, 0–1 */
66
+ confidence: number;
67
+ notes: string[];
68
+ }
69
+ /** Build the compact structured input for the discovery LLM call. */
70
+ declare function buildDiscoveryInput(map: ProjectMap, rtResult?: RuntimeDiscoveryResult): DiscoveryLlmInput;
71
+ /**
72
+ * Return true when the deterministic evidence is uncertain enough that an LLM
73
+ * call is worthwhile. Skip LLM when:
74
+ * - All static route candidates have confidence >= 0.9 AND
75
+ * - Runtime shows no unreachable routes (or runtime wasn't run)
76
+ */
77
+ declare function shouldUseLlm(map: ProjectMap, rtResult?: RuntimeDiscoveryResult): boolean;
78
+ declare function buildDiscoveryPrompt(input: DiscoveryLlmInput): string;
79
+ declare function validateDiscoveryOutput(raw: unknown, allowedRoutes: string[]): DiscoveryLlmOutput;
80
+ /**
81
+ * Ask the configured LLM provider to suggest which routes to visit and which
82
+ * hidden states to explore. Only called when `shouldUseLlm()` returns true.
83
+ *
84
+ * Throws if the provider API key is missing or the response cannot be parsed.
85
+ */
86
+ declare function runLlmDiscovery(input: DiscoveryLlmInput, aiConfig: AIConfig): Promise<DiscoveryLlmOutput>;
87
+
88
+ interface GeneratorOptions {
89
+ /** Resolved `ai` block from selfcure.config.mjs — provider + model overrides */
90
+ ai: AIConfig;
91
+ /** Output directory where generated test files will be written */
92
+ testsDir: string;
93
+ /** Cap per LLM request to avoid runaway costs */
94
+ maxInputTokens?: number;
95
+ }
96
+ interface GeneratedTest {
97
+ /** Absolute path of the generated .spec.ts file */
98
+ filePath: string;
99
+ sourceComponent: string;
100
+ testCode: string;
101
+ generatedAt: Date;
102
+ }
103
+ /**
104
+ * For each analysed component, call the configured LLM and receive a
105
+ * Playwright test file. The provider (Anthropic/OpenAI/Google/Groq/DeepSeek/
106
+ * Ollama) and model are resolved from `options.ai`.
107
+ *
108
+ * The required API key is read from the env var declared by the provider
109
+ * (e.g. ANTHROPIC_API_KEY, OPENAI_API_KEY, …).
110
+ */
111
+ declare function generate(analyses: AnalysisResult[], options: GeneratorOptions): Promise<GeneratedTest[]>;
112
+
113
+ export { type AIConfig, type DiscoveryLlmInput, type DiscoveryLlmOutput, type GeneratedTest, type GeneratorOptions, type HiddenStateHint, type ModelKind, PROVIDERS, type ProviderId, type ProviderMeta, buildDiscoveryInput, buildDiscoveryPrompt, generate as default, generate, getModel, runLlmDiscovery, shouldUseLlm, validateDiscoveryOutput };
package/dist/index.js ADDED
@@ -0,0 +1,281 @@
1
+ // src/index.ts
2
+ import { generateText as generateText2 } from "ai";
3
+
4
+ // src/ai.ts
5
+ import { createAnthropic } from "@ai-sdk/anthropic";
6
+ import { createOpenAI } from "@ai-sdk/openai";
7
+ import { createGoogleGenerativeAI } from "@ai-sdk/google";
8
+ import { createGroq } from "@ai-sdk/groq";
9
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
10
+ var PROVIDERS = {
11
+ anthropic: {
12
+ id: "anthropic",
13
+ label: "Anthropic",
14
+ envVar: "ANTHROPIC_API_KEY",
15
+ defaultGenerationModel: "claude-opus-4-7",
16
+ defaultHealingModel: "claude-haiku-4-5",
17
+ hint: "Top-tier reasoning, $$$",
18
+ apiKeyPlaceholder: "sk-ant-\u2026"
19
+ },
20
+ openai: {
21
+ id: "openai",
22
+ label: "OpenAI",
23
+ envVar: "OPENAI_API_KEY",
24
+ defaultGenerationModel: "gpt-4.1",
25
+ defaultHealingModel: "gpt-4o-mini",
26
+ hint: "Most widely available",
27
+ apiKeyPlaceholder: "sk-\u2026"
28
+ },
29
+ google: {
30
+ id: "google",
31
+ label: "Google Gemini",
32
+ envVar: "GOOGLE_GENERATIVE_AI_API_KEY",
33
+ defaultGenerationModel: "gemini-2.0-flash-exp",
34
+ defaultHealingModel: "gemini-2.0-flash-exp",
35
+ hint: "Generous free tier",
36
+ apiKeyPlaceholder: "AIza\u2026"
37
+ },
38
+ groq: {
39
+ id: "groq",
40
+ label: "Groq",
41
+ envVar: "GROQ_API_KEY",
42
+ defaultGenerationModel: "llama-3.3-70b-versatile",
43
+ defaultHealingModel: "llama-3.1-8b-instant",
44
+ hint: "Free tier, fastest inference",
45
+ apiKeyPlaceholder: "gsk_\u2026"
46
+ },
47
+ deepseek: {
48
+ id: "deepseek",
49
+ label: "DeepSeek",
50
+ envVar: "DEEPSEEK_API_KEY",
51
+ defaultGenerationModel: "deepseek-chat",
52
+ defaultHealingModel: "deepseek-chat",
53
+ defaultBaseURL: "https://api.deepseek.com/v1",
54
+ hint: "Ultra-cheap, OpenAI-compatible",
55
+ apiKeyPlaceholder: "sk-\u2026"
56
+ },
57
+ ollama: {
58
+ id: "ollama",
59
+ label: "Ollama (local)",
60
+ envVar: null,
61
+ defaultGenerationModel: "qwen2.5-coder:14b",
62
+ defaultHealingModel: "qwen2.5-coder:7b",
63
+ defaultBaseURL: "http://localhost:11434/v1",
64
+ hint: "No key, runs locally",
65
+ apiKeyPlaceholder: ""
66
+ }
67
+ };
68
+ function pickModelName(ai, kind) {
69
+ const meta = PROVIDERS[ai.provider];
70
+ if (kind === "generation") return ai.generationModel ?? meta.defaultGenerationModel;
71
+ return ai.healingModel ?? meta.defaultHealingModel;
72
+ }
73
+ function readApiKey(ai) {
74
+ const envName = ai.apiKeyEnv ?? PROVIDERS[ai.provider].envVar;
75
+ if (!envName) return void 0;
76
+ return process.env[envName];
77
+ }
78
+ function getModel(ai, kind) {
79
+ const meta = PROVIDERS[ai.provider];
80
+ const modelName = pickModelName(ai, kind);
81
+ const apiKey = readApiKey(ai);
82
+ if (meta.envVar && !apiKey) {
83
+ throw new Error(
84
+ `Missing ${meta.envVar} for provider "${ai.provider}". Set it in your .env or shell environment.`
85
+ );
86
+ }
87
+ switch (ai.provider) {
88
+ case "anthropic": {
89
+ const client = createAnthropic({ apiKey });
90
+ return client(modelName);
91
+ }
92
+ case "openai": {
93
+ const client = createOpenAI({ apiKey });
94
+ return client(modelName);
95
+ }
96
+ case "google": {
97
+ const client = createGoogleGenerativeAI({ apiKey });
98
+ return client(modelName);
99
+ }
100
+ case "groq": {
101
+ const client = createGroq({ apiKey });
102
+ return client(modelName);
103
+ }
104
+ case "deepseek": {
105
+ const client = createOpenAICompatible({
106
+ name: "deepseek",
107
+ baseURL: ai.baseURL ?? meta.defaultBaseURL,
108
+ apiKey
109
+ });
110
+ return client(modelName);
111
+ }
112
+ case "ollama": {
113
+ const client = createOpenAICompatible({
114
+ name: "ollama",
115
+ baseURL: ai.baseURL ?? meta.defaultBaseURL
116
+ });
117
+ return client(modelName);
118
+ }
119
+ }
120
+ }
121
+
122
+ // src/discovery.ts
123
+ import { generateText } from "ai";
124
+ function buildDiscoveryInput(map, rtResult) {
125
+ const scripts = [
126
+ map.devCommand,
127
+ map.buildCommand,
128
+ map.testCommand
129
+ ].filter((s) => Boolean(s));
130
+ const input = {
131
+ framework: map.framework,
132
+ packageManager: map.packageManager,
133
+ packageScripts: scripts,
134
+ routeCandidates: map.routeCandidates.map((r) => r.path)
135
+ };
136
+ if (rtResult) {
137
+ input.runtimeFindings = rtResult.routes.map((r) => ({
138
+ route: r.route,
139
+ status: r.status,
140
+ flaggedCount: r.interactiveElements.filter((e) => e.score < 80).length
141
+ }));
142
+ }
143
+ return input;
144
+ }
145
+ function shouldUseLlm(map, rtResult) {
146
+ const avgConf = map.routeCandidates.length > 0 ? map.routeCandidates.reduce((s, r) => s + r.confidence, 0) / map.routeCandidates.length : 0;
147
+ if (avgConf < 0.85) return true;
148
+ if (rtResult) {
149
+ const hasUnreachable = rtResult.routes.some((r) => r.status !== "reachable");
150
+ if (hasUnreachable) return true;
151
+ }
152
+ return false;
153
+ }
154
+ function buildDiscoveryPrompt(input) {
155
+ return [
156
+ `You are analyzing a ${input.framework} frontend application to improve testability coverage.`,
157
+ "",
158
+ "Given the project information below, identify:",
159
+ "1. Which route candidates are the most important to test (prioritise by user-journey value)",
160
+ "2. Any hidden states (modals, drawers, wizard steps, dialogs) likely triggered from those routes",
161
+ "",
162
+ "Project information:",
163
+ JSON.stringify(input, null, 2),
164
+ "",
165
+ "Respond with ONLY valid JSON \u2014 no prose, no markdown fences:",
166
+ "{",
167
+ ' "routesToVisit": ["/", "/login"],',
168
+ ' "hiddenStatesToExplore": [',
169
+ ` { "route": "/checkout", "triggerHint": "button with accessible name 'Add payment method'" }`,
170
+ " ],",
171
+ ' "confidence": 0.82,',
172
+ ' "notes": []',
173
+ "}",
174
+ "",
175
+ "Rules:",
176
+ "- routesToVisit must be a subset of routeCandidates",
177
+ "- confidence must be a number between 0 and 1",
178
+ "- hiddenStatesToExplore may be an empty array",
179
+ "- notes may be an empty array",
180
+ "- Do NOT include any explanation outside the JSON object"
181
+ ].join("\n");
182
+ }
183
+ function validateDiscoveryOutput(raw, allowedRoutes) {
184
+ if (!raw || typeof raw !== "object") {
185
+ throw new Error("Discovery LLM output is not an object");
186
+ }
187
+ const r = raw;
188
+ if (!Array.isArray(r["routesToVisit"])) {
189
+ throw new Error("routesToVisit must be an array");
190
+ }
191
+ if (typeof r["confidence"] !== "number" || r["confidence"] < 0 || r["confidence"] > 1) {
192
+ throw new Error("confidence must be a number between 0 and 1");
193
+ }
194
+ const allowed = new Set(allowedRoutes);
195
+ const routesToVisit = r["routesToVisit"].filter((x) => typeof x === "string" && allowed.has(x));
196
+ const hiddenStatesToExplore = Array.isArray(r["hiddenStatesToExplore"]) ? r["hiddenStatesToExplore"].filter(
197
+ (x) => typeof x === "object" && x !== null && typeof x["route"] === "string" && typeof x["triggerHint"] === "string"
198
+ ) : [];
199
+ const notes = Array.isArray(r["notes"]) ? r["notes"].filter((x) => typeof x === "string") : [];
200
+ return {
201
+ routesToVisit,
202
+ hiddenStatesToExplore,
203
+ confidence: r["confidence"],
204
+ notes
205
+ };
206
+ }
207
+ async function runLlmDiscovery(input, aiConfig) {
208
+ const model = getModel(aiConfig, "generation");
209
+ const prompt = buildDiscoveryPrompt(input);
210
+ const { text } = await generateText({ model, prompt, maxOutputTokens: 1e3 });
211
+ const match = text.match(/\{[\s\S]*\}/);
212
+ if (!match) {
213
+ throw new Error(`LLM response contained no JSON object.
214
+
215
+ Raw response:
216
+ ${text}`);
217
+ }
218
+ const parsed = JSON.parse(match[0]);
219
+ return validateDiscoveryOutput(parsed, input.routeCandidates);
220
+ }
221
+
222
+ // src/index.ts
223
+ function buildPrompt(analysis) {
224
+ const { component, interactiveElements, score } = analysis;
225
+ const ambiguous = interactiveElements.filter((e) => e.ambiguous);
226
+ const ambiguitySection = ambiguous.length ? `
227
+
228
+ ## Ambiguous locators
229
+ The following elements have no unique locator in this component \u2014 multiple nodes share the best available selector. For each, narrow the locator (e.g. \`page.getByRole('button', { name: ... })\`, \`.filter({ hasText })\`, \`.nth(i)\`, or scope under a parent) so each test targets exactly one node:
230
+ ${ambiguous.map((e) => `- ${e.type} [${e.selector}]${e.label ? ` \u2014 label: "${e.label}"` : ""} (${e.ambiguityReason ?? "matches multiple elements"})`).join("\n")}` : "";
231
+ return `You are an expert Playwright test engineer.
232
+
233
+ Generate a complete, runnable Playwright TypeScript test file for the component described below.
234
+
235
+ ## Component
236
+ - Name: ${component.componentName}
237
+ - Framework: ${component.framework}
238
+ - File: ${component.filePath}
239
+ - Testability score: ${score}/100
240
+
241
+ ## Interactive elements
242
+ ${interactiveElements.map((e) => `- ${e.type} [${e.selector}]${e.ambiguous ? " \u26A0 ambiguous" : ""} \u2014 actions: ${e.actions.join(", ")}`).join("\n")}${ambiguitySection}
243
+
244
+ ## Rules
245
+ - Use \`@playwright/test\` imports only.
246
+ - Each test must be independent (no shared state).
247
+ - Use accessible-name selectors (getByRole, getByLabel) over CSS selectors where possible.
248
+ - For elements flagged as ambiguous, never use the bare selector \u2014 always combine with a name, text, or scoping locator so the query resolves to exactly one node.
249
+ - Include at least one positive and one negative test case per interactive element.
250
+ - Output ONLY the TypeScript code, no markdown fences.`;
251
+ }
252
+ async function generate(analyses, options) {
253
+ const model = getModel(options.ai, "generation");
254
+ const results = [];
255
+ for (const analysis of analyses) {
256
+ const { text } = await generateText2({
257
+ model,
258
+ prompt: buildPrompt(analysis),
259
+ maxOutputTokens: options.maxInputTokens ?? 4096
260
+ });
261
+ results.push({
262
+ filePath: `${options.testsDir}/${analysis.component.componentName}.spec.ts`,
263
+ sourceComponent: analysis.component.filePath,
264
+ testCode: text,
265
+ generatedAt: /* @__PURE__ */ new Date()
266
+ });
267
+ }
268
+ return results;
269
+ }
270
+ var index_default = generate;
271
+ export {
272
+ PROVIDERS,
273
+ buildDiscoveryInput,
274
+ buildDiscoveryPrompt,
275
+ index_default as default,
276
+ generate,
277
+ getModel,
278
+ runLlmDiscovery,
279
+ shouldUseLlm,
280
+ validateDiscoveryOutput
281
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@selfcure/generator",
3
+ "version": "0.1.0",
4
+ "description": "Sends component analysis to an LLM (Anthropic/OpenAI/Google/Groq/DeepSeek/Ollama) and receives Playwright test code",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "ricardofrancocustodio",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ricardofrancocustodio/selfcure.git",
11
+ "directory": "packages/generator"
12
+ },
13
+ "homepage": "https://github.com/ricardofrancocustodio/selfcure#readme",
14
+ "main": "dist/index.js",
15
+ "types": "dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ }
21
+ },
22
+ "files": ["dist"],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "build": "tsup src/index.ts --format esm --dts",
28
+ "dev": "tsup src/index.ts --format esm --watch",
29
+ "test": "vitest"
30
+ },
31
+ "dependencies": {
32
+ "@selfcure/analyzer": "^0.1.0",
33
+ "ai": "^6.0.191",
34
+ "@ai-sdk/anthropic": "^3.0.79",
35
+ "@ai-sdk/openai": "^3.0.65",
36
+ "@ai-sdk/google": "^3.0.79",
37
+ "@ai-sdk/groq": "^3.0.39",
38
+ "@ai-sdk/openai-compatible": "^2.0.48"
39
+ }
40
+ }