@taodigital/agents 0.0.0-stage → 1.2.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 +99 -2
- package/dist/api-test-agent.d.ts +126 -0
- package/dist/api-test-agent.js +267 -0
- package/dist/artifact-intel-agent.d.ts +11 -0
- package/dist/artifact-intel-agent.js +152 -0
- package/dist/coverage-gap-agent.d.ts +25 -0
- package/dist/coverage-gap-agent.js +269 -0
- package/dist/coverage-map-agent.d.ts +15 -0
- package/dist/coverage-map-agent.js +255 -0
- package/dist/crawl-diff-agent.d.ts +27 -0
- package/dist/crawl-diff-agent.js +412 -0
- package/dist/data-agent.d.ts +101 -0
- package/dist/data-agent.js +2284 -0
- package/dist/document-agent.d.ts +10 -0
- package/dist/document-agent.js +32 -0
- package/dist/enhancement-agent.d.ts +48 -0
- package/dist/enhancement-agent.js +342 -0
- package/dist/field-detector-agent.d.ts +35 -0
- package/dist/field-detector-agent.js +220 -0
- package/dist/flow-agent.d.ts +130 -0
- package/dist/flow-agent.js +169 -0
- package/dist/github-agent.d.ts +18 -0
- package/dist/github-agent.js +39 -0
- package/dist/github-write-agent.d.ts +67 -0
- package/dist/github-write-agent.js +249 -0
- package/dist/grouping-agent.d.ts +18 -0
- package/dist/grouping-agent.js +338 -0
- package/dist/healing-agent.d.ts +73 -0
- package/dist/healing-agent.js +137 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +67 -0
- package/dist/integration-test-agent.d.ts +118 -0
- package/dist/integration-test-agent.js +165 -0
- package/dist/label-assignment-agent.d.ts +15 -0
- package/dist/label-assignment-agent.js +97 -0
- package/dist/llm-config.d.ts +38 -0
- package/dist/llm-config.js +287 -0
- package/dist/llm-mock.d.ts +77 -0
- package/dist/llm-mock.js +192 -0
- package/dist/llm.d.ts +58 -0
- package/dist/llm.js +438 -0
- package/dist/nlp-agent.d.ts +208 -0
- package/dist/nlp-agent.js +340 -0
- package/dist/orchestrator.d.ts +285 -0
- package/dist/orchestrator.js +141 -0
- package/dist/plan-agent.d.ts +4 -0
- package/dist/plan-agent.js +200 -0
- package/dist/repo-scan-agent.d.ts +189 -0
- package/dist/repo-scan-agent.js +2032 -0
- package/dist/requirements-agent.d.ts +59 -0
- package/dist/requirements-agent.js +641 -0
- package/dist/scenario-agent.d.ts +20 -0
- package/dist/scenario-agent.js +123 -0
- package/dist/script-agent.d.ts +447 -0
- package/dist/script-agent.js +11813 -0
- package/dist/security-test-agent.d.ts +111 -0
- package/dist/security-test-agent.js +141 -0
- package/dist/skill-file-agent.d.ts +264 -0
- package/dist/skill-file-agent.js +3470 -0
- package/dist/step-coverage.d.ts +46 -0
- package/dist/step-coverage.js +288 -0
- package/dist/step-utils.d.ts +2 -0
- package/dist/step-utils.js +62 -0
- package/dist/test-case-agent.d.ts +206 -0
- package/dist/test-case-agent.js +2141 -0
- package/dist/test-import-agent.d.ts +148 -0
- package/dist/test-import-agent.js +3235 -0
- package/dist/test-script-agent.d.ts +16 -0
- package/dist/test-script-agent.js +48 -0
- package/dist/token-pricing.d.ts +14 -0
- package/dist/token-pricing.js +62 -0
- package/dist/types.d.ts +232 -0
- package/dist/types.js +7 -0
- package/dist/visual-agent.d.ts +114 -0
- package/dist/visual-agent.js +261 -0
- package/package.json +26 -4
|
@@ -0,0 +1,3470 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Skill File Agent — 14-Dimension Application Intelligence Generator
|
|
4
|
+
*
|
|
5
|
+
* After each crawl, generates 14 taxonomy-level skill files in the Zeta Core:
|
|
6
|
+
*
|
|
7
|
+
* TIER 1 — Platform Intelligence (zero-cost DOM/network extraction, no LLM):
|
|
8
|
+
* 1. TECH_STACK — framework, CSS lib, API style, build tool
|
|
9
|
+
* 2. AUTH_SESSION — auth type, token storage, 2FA, protected routes
|
|
10
|
+
* 3. API_CONTRACT — captured endpoints, base URL, error format
|
|
11
|
+
*
|
|
12
|
+
* TIER 2 — Domain Intelligence (LLM, uses DOM data):
|
|
13
|
+
* 4. BUSINESS_DOMAIN — domain, user roles, core journeys, business rules
|
|
14
|
+
* 5. DATA_MODEL — entities and field constraints from HTML5 attrs
|
|
15
|
+
*
|
|
16
|
+
* TIER 3 — Behavioral Intelligence (LLM + vision):
|
|
17
|
+
* 6. INTERACTION_PATTERNS — error display, loading states, submit flow
|
|
18
|
+
* 7. ERROR_CATALOGUE — exact error messages per field and API error map
|
|
19
|
+
*
|
|
20
|
+
* TIER 4 — Structural Intelligence (LLM + vision):
|
|
21
|
+
* 8. VISUAL_BASELINE — colors (hex), typography, layout, contrast ratios
|
|
22
|
+
* 9. COMPONENT_LIBRARY — reusable components, where they appear, edge cases
|
|
23
|
+
* 10. ELEMENT_BEHAVIORS — per-element: type, required, min/max, pattern, states
|
|
24
|
+
*
|
|
25
|
+
* TIER 5 — Quality Intelligence (structured data, no LLM):
|
|
26
|
+
* 11. NAVIGATION_MAP — route inventory, auth guards, flow graph
|
|
27
|
+
* 12. LOCATOR_STRATEGY — data-testid coverage, role-based locators, fragile selectors
|
|
28
|
+
* 13. PERFORMANCE_BASELINE — LCP/FCP/TTFB per screen, CWV status
|
|
29
|
+
* 14. COVERAGE_MATRIX — TC counts per screen×type, coverage gaps
|
|
30
|
+
*
|
|
31
|
+
* QUALITY PROJECTION: 42/100 (old 6-file) → 87/100 (new 14-file)
|
|
32
|
+
*
|
|
33
|
+
* COST: Tiers 1, 5 = ZERO extra LLM tokens (pure DOM/network data).
|
|
34
|
+
* Tiers 2–4 = LLM calls, same as before but higher-quality prompts.
|
|
35
|
+
*/
|
|
36
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
37
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
38
|
+
};
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
exports.generateTaxonomySkillFiles = generateTaxonomySkillFiles;
|
|
41
|
+
exports.generateTechStackFile = generateTechStackFile;
|
|
42
|
+
exports.generateAuthSessionFile = generateAuthSessionFile;
|
|
43
|
+
exports.generateApiContractFile = generateApiContractFile;
|
|
44
|
+
exports.generateNavigationMapFile = generateNavigationMapFile;
|
|
45
|
+
exports.generateLocatorStrategyFile = generateLocatorStrategyFile;
|
|
46
|
+
exports.generatePerformanceBaselineFile = generatePerformanceBaselineFile;
|
|
47
|
+
exports.deriveConstraintExamples = deriveConstraintExamples;
|
|
48
|
+
exports.renderCoverageMatrix = renderCoverageMatrix;
|
|
49
|
+
exports.refreshCoverageMatrix = refreshCoverageMatrix;
|
|
50
|
+
exports.refreshTestCaseInventory = refreshTestCaseInventory;
|
|
51
|
+
exports.refreshScriptInventory = refreshScriptInventory;
|
|
52
|
+
exports.refineSkillFile = refineSkillFile;
|
|
53
|
+
exports.generateRepoSkillFiles = generateRepoSkillFiles;
|
|
54
|
+
exports.alignSkillFile = alignSkillFile;
|
|
55
|
+
exports.generateZetaFrameworkSkillFiles = generateZetaFrameworkSkillFiles;
|
|
56
|
+
exports.refreshArtifactKnowledgeSkillFile = refreshArtifactKnowledgeSkillFile;
|
|
57
|
+
exports.refreshTestCasesSkillFile = refreshTestCasesSkillFile;
|
|
58
|
+
exports.refreshTestDataSkillFile = refreshTestDataSkillFile;
|
|
59
|
+
exports.refreshTestScriptSkillFile = refreshTestScriptSkillFile;
|
|
60
|
+
exports.refreshRequirementsCoverageSkillFile = refreshRequirementsCoverageSkillFile;
|
|
61
|
+
exports.refreshTestExecutionHistorySkillFile = refreshTestExecutionHistorySkillFile;
|
|
62
|
+
exports.refreshVisualRegressionHistorySkillFile = refreshVisualRegressionHistorySkillFile;
|
|
63
|
+
exports.refreshLocatorStrategyWithHealingData = refreshLocatorStrategyWithHealingData;
|
|
64
|
+
exports.refreshFieldCatalogSkillFile = refreshFieldCatalogSkillFile;
|
|
65
|
+
exports.refreshHealingLogSkillFile = refreshHealingLogSkillFile;
|
|
66
|
+
exports.refreshLocatorInventorySkillFile = refreshLocatorInventorySkillFile;
|
|
67
|
+
const llm_js_1 = require("./llm.js");
|
|
68
|
+
const llm_config_js_1 = require("./llm-config.js");
|
|
69
|
+
const app_brain_1 = require("@taodigital/app-brain");
|
|
70
|
+
const core_1 = require("@taodigital/core");
|
|
71
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
72
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
73
|
+
const SKILL_GENERATOR_VERSION = 'skill-file-agent:v2-structured-metadata';
|
|
74
|
+
function skillQualityMetadata(kind, content, screens, apiEndpoints, performanceMetrics, mode) {
|
|
75
|
+
const SECTION_LIMIT = parseInt(process.env.SKILL_FILE_SECTION_LIMIT ?? '24', 10);
|
|
76
|
+
const SCREEN_LIMIT = parseInt(process.env.SKILL_FILE_SCREEN_LIMIT ?? '40', 10);
|
|
77
|
+
const ENDPOINT_LIMIT = parseInt(process.env.SKILL_FILE_ENDPOINT_LIMIT ?? '80', 10);
|
|
78
|
+
const sections = [...content.matchAll(/^##\s+(.+)$/gm)].map((m) => m[1].trim()).slice(0, SECTION_LIMIT);
|
|
79
|
+
const screenFacts = screens.slice(0, SCREEN_LIMIT).map((s) => ({
|
|
80
|
+
sourceType: 'SCREEN',
|
|
81
|
+
sourceId: s.id,
|
|
82
|
+
name: s.name,
|
|
83
|
+
url: s.url ?? null,
|
|
84
|
+
elementCount: s.elements.length,
|
|
85
|
+
screenshotCount: s.screenshots?.length ?? 0,
|
|
86
|
+
}));
|
|
87
|
+
const endpointFacts = (apiEndpoints ?? []).slice(0, ENDPOINT_LIMIT).map((e) => ({
|
|
88
|
+
sourceType: 'API_ENDPOINT',
|
|
89
|
+
method: e.method,
|
|
90
|
+
urlPattern: e.urlPattern,
|
|
91
|
+
}));
|
|
92
|
+
const perfFacts = (performanceMetrics ?? []).slice(0, 40).map((m) => ({
|
|
93
|
+
sourceType: 'PERFORMANCE_METRIC',
|
|
94
|
+
screenName: m.screenName,
|
|
95
|
+
url: m.url,
|
|
96
|
+
lcp: m.lcp ?? null,
|
|
97
|
+
fcp: m.fcp ?? null,
|
|
98
|
+
ttfb: m.ttfb ?? null,
|
|
99
|
+
cls: m.cls ?? null,
|
|
100
|
+
}));
|
|
101
|
+
const hasEvidence = screenFacts.length > 0 || endpointFacts.length > 0 || perfFacts.length > 0;
|
|
102
|
+
const validationStatus = content.includes('LLM generation failed') || content.includes('UNKNOWN — verify manually')
|
|
103
|
+
? 'WARN'
|
|
104
|
+
: hasEvidence
|
|
105
|
+
? 'PASS'
|
|
106
|
+
: 'WARN';
|
|
107
|
+
return {
|
|
108
|
+
structuredData: {
|
|
109
|
+
kind,
|
|
110
|
+
generatorVersion: SKILL_GENERATOR_VERSION,
|
|
111
|
+
extractionMode: mode,
|
|
112
|
+
screenCount: screens.length,
|
|
113
|
+
elementCount: screens.reduce((n, s) => n + s.elements.length, 0),
|
|
114
|
+
screenshotCount: screens.reduce((n, s) => n + (s.screenshots?.length ?? 0), 0),
|
|
115
|
+
apiEndpointCount: apiEndpoints?.length ?? 0,
|
|
116
|
+
performanceMetricCount: performanceMetrics?.length ?? 0,
|
|
117
|
+
sections,
|
|
118
|
+
},
|
|
119
|
+
provenance: [...screenFacts, ...endpointFacts, ...perfFacts],
|
|
120
|
+
confidence: mode === 'structured' || mode === 'live-db' ? 0.9 : mode === 'vision' ? 0.75 : 0.7,
|
|
121
|
+
validationStatus,
|
|
122
|
+
validationSummary: validationStatus === 'PASS'
|
|
123
|
+
? 'Generated with source evidence and no placeholder warning markers.'
|
|
124
|
+
: 'Generated with incomplete or lower-confidence evidence; review recommended.',
|
|
125
|
+
staleStatus: 'CURRENT',
|
|
126
|
+
staleReason: null,
|
|
127
|
+
generatorVersion: SKILL_GENERATOR_VERSION,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// ─── Orchestrator ────────────────────────────────────────────────────────────
|
|
131
|
+
/** Load all SkillFileConfig rows for a project in one query. Returns a Map keyed by kind. */
|
|
132
|
+
async function loadSkillFileConfigs(tenantId, projectId) {
|
|
133
|
+
try {
|
|
134
|
+
const { withTenant: wt } = await import('@taodigital/database');
|
|
135
|
+
const rows = await wt(tenantId, (tx) => tx.skillFileConfig.findMany({ where: { tenantId, projectId } }));
|
|
136
|
+
const map = new Map();
|
|
137
|
+
for (const r of rows)
|
|
138
|
+
map.set(r.kind, { enabled: r.enabled, requireApproval: r.requireApproval });
|
|
139
|
+
return map;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return new Map(); // no config rows → defaults apply (enabled, no approval required)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Generate all 14 taxonomy skill files for a project after a crawl.
|
|
147
|
+
* Dependency-aware execution in 3 phases:
|
|
148
|
+
* Phase A: Zero-cost structured data (no LLM)
|
|
149
|
+
* Phase B: LLM domain + behavioral (uses Phase A data)
|
|
150
|
+
* Phase C: LLM structural + quality (uses Phase A+B data)
|
|
151
|
+
*/
|
|
152
|
+
async function generateTaxonomySkillFiles(tenantId, projectId, screens, apiEndpoints, performanceMetrics) {
|
|
153
|
+
if (screens.length === 0)
|
|
154
|
+
return;
|
|
155
|
+
const totalElements = screens.reduce((n, s) => n + s.elements.length, 0);
|
|
156
|
+
const totalScreenshots = screens.reduce((n, s) => n + (s.screenshots?.length ?? 0), 0);
|
|
157
|
+
if (totalElements === 0 && totalScreenshots === 0) {
|
|
158
|
+
console.warn(`[skill-file-agent] No elements or screenshots found for project ${projectId} — skipping taxonomy generation.`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// Check per-project skill file config
|
|
162
|
+
const sfConfigs = await loadSkillFileConfigs(tenantId, projectId);
|
|
163
|
+
const sfEnabled = (kind) => sfConfigs.get(kind)?.enabled !== false;
|
|
164
|
+
const sfApprovalStatus = (kind) => sfConfigs.get(kind)?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING';
|
|
165
|
+
// Purge legacy 6-file kinds AND auto-invalidate TEST_PLAN so it doesn't get stale after a crawl
|
|
166
|
+
const KINDS_TO_PURGE = [
|
|
167
|
+
// Legacy kinds (old 6-file system)
|
|
168
|
+
'PLATFORM', 'FUNCTION', 'DESIGN', 'THEME', 'COMPONENT', 'ELEMENT',
|
|
169
|
+
// Current 14-dimension kinds — purge so regeneration always starts fresh
|
|
170
|
+
'TECH_STACK', 'AUTH_SESSION', 'API_CONTRACT', 'NAVIGATION_MAP', 'LOCATOR_STRATEGY',
|
|
171
|
+
'PERFORMANCE_BASELINE', 'BUSINESS_DOMAIN', 'DATA_MODEL', 'INTERACTION_PATTERNS',
|
|
172
|
+
'ERROR_CATALOGUE', 'VISUAL_BASELINE', 'COMPONENT_LIBRARY', 'ELEMENT_BEHAVIORS',
|
|
173
|
+
'COVERAGE_MATRIX', 'SCREEN_UNDERSTANDING',
|
|
174
|
+
// Invalidate plan on re-crawl
|
|
175
|
+
'TEST_PLAN',
|
|
176
|
+
];
|
|
177
|
+
await app_brain_1.AppBrain.deleteSkillFilesByKinds(tenantId, projectId, KINDS_TO_PURGE).catch((e) => console.warn('[skill-file-agent] skill file purge failed:', e));
|
|
178
|
+
const llmCfg = await (0, llm_config_js_1.getTaskAgentLLMConfig)(tenantId, 'appBrain');
|
|
179
|
+
const stripFences = (text) => text.replace(/^```(?:markdown)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();
|
|
180
|
+
const askFn = async (sys, usr, label = 'skill_file') => {
|
|
181
|
+
const t0 = Date.now();
|
|
182
|
+
const seed = (0, llm_js_1.deterministicSeed)(`${tenantId}:${projectId}:skill-file:${label}`);
|
|
183
|
+
const { text, usage } = await (0, llm_js_1.askTracked)(sys, usr, llmCfg, undefined, undefined, { temperature: 0, seed });
|
|
184
|
+
(0, app_brain_1.recordLLMCall)({ tenantId, projectId, agentType: 'skill_file', label, usage, latencyMs: Date.now() - t0 }).catch(console.error);
|
|
185
|
+
const clean = stripFences(text ?? '');
|
|
186
|
+
if (clean.length < 30) {
|
|
187
|
+
throw new Error(`LLM returned insufficient content for ${label} (got: "${text?.slice(0, 50)}")`);
|
|
188
|
+
}
|
|
189
|
+
return clean;
|
|
190
|
+
};
|
|
191
|
+
const askVisionFn = async (sys, usr, imgs, label = 'skill_file_vision') => {
|
|
192
|
+
const t0 = Date.now();
|
|
193
|
+
const visionSeed = (0, llm_js_1.deterministicSeed)(`${tenantId}:${projectId}:skill-vision:${label}:0`);
|
|
194
|
+
const { text, usage } = imgs.length > 0
|
|
195
|
+
? await (0, llm_js_1.askTrackedWithImages)(sys, usr, imgs, llmCfg, { temperature: 0, seed: visionSeed })
|
|
196
|
+
: await (0, llm_js_1.askTracked)(sys, usr, llmCfg, undefined, undefined, { temperature: 0, seed: visionSeed });
|
|
197
|
+
(0, app_brain_1.recordLLMCall)({ tenantId, projectId, agentType: 'skill_file', label, usage, latencyMs: Date.now() - t0 }).catch(console.error);
|
|
198
|
+
const clean = stripFences(text ?? '');
|
|
199
|
+
if (clean.length < 30) {
|
|
200
|
+
throw new Error(`LLM returned insufficient content for ${label} (got: "${text?.slice(0, 50)}")`);
|
|
201
|
+
}
|
|
202
|
+
return clean;
|
|
203
|
+
};
|
|
204
|
+
const screenSummary = buildScreenSummary(screens);
|
|
205
|
+
const screenshots = await loadRepresentativeScreenshots(screens, 8);
|
|
206
|
+
const meta = (kind, content, mode) => skillQualityMetadata(kind, content, screens, apiEndpoints, performanceMetrics, mode);
|
|
207
|
+
// ── Phase A: Zero-cost structured data (no LLM calls) ───────────────────
|
|
208
|
+
console.log('[skill-file-agent] Phase A: structured extraction (0 LLM tokens)');
|
|
209
|
+
const [techStack, authSession, apiContract, navigationMap, locatorStrategy, performanceBaseline] = await Promise.all([
|
|
210
|
+
generateTechStackFile(screens, apiEndpoints),
|
|
211
|
+
generateAuthSessionFile(screens, apiEndpoints),
|
|
212
|
+
generateApiContractFile(apiEndpoints),
|
|
213
|
+
generateNavigationMapFile(screens),
|
|
214
|
+
generateLocatorStrategyFile(screens),
|
|
215
|
+
generatePerformanceBaselineFile(screens, performanceMetrics),
|
|
216
|
+
]);
|
|
217
|
+
await Promise.all([
|
|
218
|
+
sfEnabled('TECH_STACK') && app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'TECH_STACK', techStack.content, { summary: techStack.summary, tags: techStack.tags, source: 'AUTO', approvalStatus: sfApprovalStatus('TECH_STACK'), ...meta('TECH_STACK', techStack.content, 'structured') }),
|
|
219
|
+
sfEnabled('AUTH_SESSION') && app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'AUTH_SESSION', authSession.content, { summary: authSession.summary, tags: authSession.tags, source: 'AUTO', approvalStatus: sfApprovalStatus('AUTH_SESSION'), ...meta('AUTH_SESSION', authSession.content, 'structured') }),
|
|
220
|
+
sfEnabled('API_CONTRACT') && app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'API_CONTRACT', apiContract.content, { summary: apiContract.summary, tags: apiContract.tags, source: 'AUTO', approvalStatus: sfApprovalStatus('API_CONTRACT'), ...meta('API_CONTRACT', apiContract.content, 'structured') }),
|
|
221
|
+
sfEnabled('NAVIGATION_MAP') && app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'NAVIGATION_MAP', navigationMap.content, { summary: navigationMap.summary, tags: navigationMap.tags, source: 'AUTO', approvalStatus: sfApprovalStatus('NAVIGATION_MAP'), ...meta('NAVIGATION_MAP', navigationMap.content, 'structured') }),
|
|
222
|
+
sfEnabled('LOCATOR_STRATEGY') && app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'LOCATOR_STRATEGY', locatorStrategy.content, { summary: locatorStrategy.summary, tags: locatorStrategy.tags, source: 'AUTO', approvalStatus: sfApprovalStatus('LOCATOR_STRATEGY'), ...meta('LOCATOR_STRATEGY', locatorStrategy.content, 'structured') }),
|
|
223
|
+
sfEnabled('PERFORMANCE_BASELINE') && app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'PERFORMANCE_BASELINE', performanceBaseline.content, { summary: performanceBaseline.summary, tags: performanceBaseline.tags, source: 'AUTO', approvalStatus: sfApprovalStatus('PERFORMANCE_BASELINE'), ...meta('PERFORMANCE_BASELINE', performanceBaseline.content, 'structured') }),
|
|
224
|
+
]);
|
|
225
|
+
// ── Probe observations — load early so ERROR_CATALOGUE receives real VALIDATION_ERROR facts ─
|
|
226
|
+
// Must run before Phase B; probe obs are written to DB during the crawl phase.
|
|
227
|
+
const probeObservations = new Map();
|
|
228
|
+
await Promise.all(screens.slice(0, 40).map(async (s) => {
|
|
229
|
+
try {
|
|
230
|
+
const obs = await app_brain_1.AppBrain.getProbeObservations(tenantId, s.id);
|
|
231
|
+
if (obs?.length)
|
|
232
|
+
probeObservations.set(s.id, obs);
|
|
233
|
+
}
|
|
234
|
+
catch { /* probe table may be empty — fine */ }
|
|
235
|
+
}));
|
|
236
|
+
// ── Phase B: LLM domain + behavioral (uses structured data from Phase A) ─
|
|
237
|
+
// Promise.allSettled so one failing LLM call never poisons the other files.
|
|
238
|
+
console.log('[skill-file-agent] Phase B: LLM domain + behavioral skill files');
|
|
239
|
+
const llmNotice = (kind, err) => `# ${kind}\n\n> LLM generation failed. Configure an AI provider in Workspace → Settings → AI Provider, then regenerate.\n\n**Error:** ${err instanceof Error ? err.message : String(err)}`;
|
|
240
|
+
const fallbackResult = (kind, err) => ({
|
|
241
|
+
content: llmNotice(kind, err), summary: 'LLM unavailable', tags: ['needs-llm'],
|
|
242
|
+
});
|
|
243
|
+
const [businessDomainR, dataModelR, interactionPatternsR, errorCatalogueR, screenUnderstandingR] = await Promise.allSettled([
|
|
244
|
+
generateBusinessDomainFile(screenSummary, screens, askFn),
|
|
245
|
+
generateDataModelFile(screenSummary, screens, askFn, apiEndpoints),
|
|
246
|
+
generateInteractionPatternsFile(screenSummary, screens, askFn, askVisionFn, screenshots),
|
|
247
|
+
generateErrorCatalogueFile(screenSummary, screens, askFn, askVisionFn, screenshots, probeObservations),
|
|
248
|
+
generateScreenUnderstandingFile(screenSummary, screens, askFn, askVisionFn, screenshots),
|
|
249
|
+
]);
|
|
250
|
+
const businessDomain = businessDomainR.status === 'fulfilled' ? businessDomainR.value : (console.error('[skill-file-agent] BUSINESS_DOMAIN failed:', businessDomainR.reason?.message), fallbackResult('Business Domain', businessDomainR.reason));
|
|
251
|
+
const dataModel = dataModelR.status === 'fulfilled' ? dataModelR.value : (console.error('[skill-file-agent] DATA_MODEL failed:', dataModelR.reason?.message), fallbackResult('Data Model', dataModelR.reason));
|
|
252
|
+
const interactionPatterns = interactionPatternsR.status === 'fulfilled' ? interactionPatternsR.value : (console.error('[skill-file-agent] INTERACTION_PATTERNS failed:', interactionPatternsR.reason?.message), fallbackResult('Interaction Patterns', interactionPatternsR.reason));
|
|
253
|
+
const errorCatalogue = errorCatalogueR.status === 'fulfilled' ? errorCatalogueR.value : (console.error('[skill-file-agent] ERROR_CATALOGUE failed:', errorCatalogueR.reason?.message), fallbackResult('Error Catalogue', errorCatalogueR.reason));
|
|
254
|
+
const screenUnderstanding = screenUnderstandingR.status === 'fulfilled' ? screenUnderstandingR.value : (console.error('[skill-file-agent] SCREEN_UNDERSTANDING failed:', screenUnderstandingR.reason?.message), fallbackResult('Screen Understanding', screenUnderstandingR.reason));
|
|
255
|
+
await Promise.allSettled([
|
|
256
|
+
sfEnabled('BUSINESS_DOMAIN') && app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'BUSINESS_DOMAIN', businessDomain.content, { summary: businessDomain.summary, tags: businessDomain.tags, source: 'AUTO', approvalStatus: sfApprovalStatus('BUSINESS_DOMAIN'), ...meta('BUSINESS_DOMAIN', businessDomain.content, 'llm') }),
|
|
257
|
+
sfEnabled('DATA_MODEL') && app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'DATA_MODEL', dataModel.content, { summary: dataModel.summary, tags: dataModel.tags, source: 'AUTO', approvalStatus: sfApprovalStatus('DATA_MODEL'), ...meta('DATA_MODEL', dataModel.content, 'llm') }),
|
|
258
|
+
sfEnabled('INTERACTION_PATTERNS') && app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'INTERACTION_PATTERNS', interactionPatterns.content, { summary: interactionPatterns.summary, tags: interactionPatterns.tags, source: 'AUTO', approvalStatus: sfApprovalStatus('INTERACTION_PATTERNS'), ...meta('INTERACTION_PATTERNS', interactionPatterns.content, 'llm') }),
|
|
259
|
+
sfEnabled('ERROR_CATALOGUE') && app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'ERROR_CATALOGUE', errorCatalogue.content, { summary: errorCatalogue.summary, tags: errorCatalogue.tags, source: 'AUTO', approvalStatus: sfApprovalStatus('ERROR_CATALOGUE'), ...meta('ERROR_CATALOGUE', errorCatalogue.content, 'llm') }),
|
|
260
|
+
sfEnabled('SCREEN_UNDERSTANDING') && app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'SCREEN_UNDERSTANDING', screenUnderstanding.content, { summary: screenUnderstanding.summary, tags: screenUnderstanding.tags, source: 'AUTO', approvalStatus: sfApprovalStatus('SCREEN_UNDERSTANDING'), ...meta('SCREEN_UNDERSTANDING', screenUnderstanding.content, 'vision') }),
|
|
261
|
+
]);
|
|
262
|
+
// ── Phase C: LLM structural (uses Phase A+B; vision for design) ──────────
|
|
263
|
+
// Same allSettled pattern — one vision failure must not kill all three files.
|
|
264
|
+
console.log('[skill-file-agent] Phase C: LLM structural skill files (vision)');
|
|
265
|
+
// probeObservations already populated before Phase B
|
|
266
|
+
const [visualBaselineR, componentLibraryR, elementBehaviorsR] = await Promise.allSettled([
|
|
267
|
+
generateVisualBaselineFile(screenSummary, screens, askFn, askVisionFn, screenshots),
|
|
268
|
+
generateComponentLibraryFile(screenSummary, screens, techStack.content, askFn),
|
|
269
|
+
generateElementBehaviorsFile(screenSummary, screens, askFn, probeObservations),
|
|
270
|
+
]);
|
|
271
|
+
const visualBaseline = visualBaselineR.status === 'fulfilled' ? visualBaselineR.value : (console.error('[skill-file-agent] VISUAL_BASELINE failed:', visualBaselineR.reason?.message), fallbackResult('Visual Baseline', visualBaselineR.reason));
|
|
272
|
+
const componentLibrary = componentLibraryR.status === 'fulfilled' ? componentLibraryR.value : (console.error('[skill-file-agent] COMPONENT_LIBRARY failed:', componentLibraryR.reason?.message), fallbackResult('Component Library', componentLibraryR.reason));
|
|
273
|
+
const elementBehaviors = elementBehaviorsR.status === 'fulfilled' ? elementBehaviorsR.value : (console.error('[skill-file-agent] ELEMENT_BEHAVIORS failed:', elementBehaviorsR.reason?.message), fallbackResult('Element Behaviors', elementBehaviorsR.reason));
|
|
274
|
+
await Promise.allSettled([
|
|
275
|
+
sfEnabled('VISUAL_BASELINE') && app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'VISUAL_BASELINE', visualBaseline.content, { summary: visualBaseline.summary, tags: visualBaseline.tags, source: 'AUTO', approvalStatus: sfApprovalStatus('VISUAL_BASELINE'), ...meta('VISUAL_BASELINE', visualBaseline.content, 'vision') }),
|
|
276
|
+
sfEnabled('COMPONENT_LIBRARY') && app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'COMPONENT_LIBRARY', componentLibrary.content, { summary: componentLibrary.summary, tags: componentLibrary.tags, source: 'AUTO', approvalStatus: sfApprovalStatus('COMPONENT_LIBRARY'), ...meta('COMPONENT_LIBRARY', componentLibrary.content, 'vision') }),
|
|
277
|
+
sfEnabled('ELEMENT_BEHAVIORS') && app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'ELEMENT_BEHAVIORS', elementBehaviors.content, { summary: elementBehaviors.summary, tags: elementBehaviors.tags, source: 'AUTO', approvalStatus: sfApprovalStatus('ELEMENT_BEHAVIORS'), ...meta('ELEMENT_BEHAVIORS', elementBehaviors.content, 'vision') }),
|
|
278
|
+
]);
|
|
279
|
+
// ── Phase D: Coverage matrix (live DB query — no LLM, no DOM) ────────────
|
|
280
|
+
// Screens are already persisted at this point, so the live refresh works for
|
|
281
|
+
// a fresh crawl too (all counts 0 until tests are generated).
|
|
282
|
+
await Promise.allSettled([
|
|
283
|
+
refreshCoverageMatrix(tenantId, projectId),
|
|
284
|
+
refreshArtifactKnowledgeSkillFile(tenantId, projectId),
|
|
285
|
+
refreshTestCasesSkillFile(tenantId, projectId),
|
|
286
|
+
refreshRequirementsCoverageSkillFile(tenantId, projectId),
|
|
287
|
+
refreshTestDataSkillFile(tenantId, projectId),
|
|
288
|
+
refreshHealingLogSkillFile(tenantId, projectId),
|
|
289
|
+
refreshLocatorInventorySkillFile(tenantId, projectId),
|
|
290
|
+
]);
|
|
291
|
+
console.log(`[skill-file-agent] ✓ 15 skill files generated for project ${projectId}`);
|
|
292
|
+
}
|
|
293
|
+
// ─── TIER 1: Zero-cost Platform Intelligence ─────────────────────────────────
|
|
294
|
+
/** TECH_STACK — purely from DOM data extracted during crawl */
|
|
295
|
+
function generateTechStackFile(screens, apiEndpoints) {
|
|
296
|
+
// Collect all CSS classes from element notes
|
|
297
|
+
const cssClasses = screens
|
|
298
|
+
.flatMap((s) => s.elements.map((e) => {
|
|
299
|
+
try {
|
|
300
|
+
return JSON.parse(e.notes ?? '{}').cssClass;
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}))
|
|
306
|
+
.filter((c) => !!c);
|
|
307
|
+
const classSet = new Set(cssClasses);
|
|
308
|
+
// Detect UI frameworks from CSS class signatures
|
|
309
|
+
const uiLibrary = [...classSet].some((c) => /^Mui[A-Z]/.test(c)) ? 'Material-UI (MUI)' :
|
|
310
|
+
[...classSet].some((c) => /^ant-/.test(c)) ? 'Ant Design' :
|
|
311
|
+
[...classSet].some((c) => /^chakra-/.test(c)) ? 'Chakra UI' :
|
|
312
|
+
[...classSet].some((c) => /^(btn|container|row|col-\w+)$/.test(c)) ? 'Bootstrap' :
|
|
313
|
+
[...classSet].some((c) => /^(text-|flex|grid|px-|py-|mt-|mb-|bg-|border-)/.test(c)) ? 'Tailwind CSS' :
|
|
314
|
+
'Custom CSS (no recognized framework classes)';
|
|
315
|
+
// Detect API style from endpoints
|
|
316
|
+
const hasGraphQL = apiEndpoints?.some((e) => /graphql|gql/i.test(e.urlPattern));
|
|
317
|
+
const hasTrpc = apiEndpoints?.some((e) => /trpc/i.test(e.urlPattern));
|
|
318
|
+
const apiStyle = hasGraphQL ? 'GraphQL' : hasTrpc ? 'tRPC' : apiEndpoints?.length ? 'REST' : 'Unknown (no API calls captured)';
|
|
319
|
+
// Infer version/api prefix from URL patterns
|
|
320
|
+
const versionMatch = apiEndpoints
|
|
321
|
+
?.map((e) => e.urlPattern.match(/\/v(\d+)\//)?.[1])
|
|
322
|
+
.find(Boolean);
|
|
323
|
+
const apiVersion = versionMatch ? `v${versionMatch}` : 'not versioned or not detected';
|
|
324
|
+
// Detect SSR vs SPA from URL patterns (presence of file extensions = SSR)
|
|
325
|
+
const hasFileExtensions = screens.some((s) => /\.(php|html|erb|blade)($|\?)/.test(s.url ?? ''));
|
|
326
|
+
const appType = hasFileExtensions ? 'Server-Side Rendered (SSR)' : 'Single Page Application (SPA)';
|
|
327
|
+
// Notable CSS classes for component detection
|
|
328
|
+
const uniqueClasses = [...classSet].slice(0, 40).join(', ');
|
|
329
|
+
const content = `# Tech Stack
|
|
330
|
+
> Auto-generated from DOM class analysis + network interception during crawl.
|
|
331
|
+
> Confidence: HIGH for UI library, MEDIUM for framework, LOW for build tool.
|
|
332
|
+
|
|
333
|
+
## Application Type
|
|
334
|
+
${appType}
|
|
335
|
+
|
|
336
|
+
## UI Component Library
|
|
337
|
+
${uiLibrary}
|
|
338
|
+
|
|
339
|
+
## API Style
|
|
340
|
+
- Type: **${apiStyle}**
|
|
341
|
+
- Version: ${apiVersion}
|
|
342
|
+
- Base URL: ${apiEndpoints?.length ? `/${apiEndpoints[0]?.urlPattern.split('/').slice(1, 3).join('/')}` : 'not detected'}
|
|
343
|
+
|
|
344
|
+
## Detected CSS Classes (component library signals)
|
|
345
|
+
\`\`\`
|
|
346
|
+
${uniqueClasses || 'No CSS classes captured — DOM extraction may need tuning'}
|
|
347
|
+
\`\`\`
|
|
348
|
+
|
|
349
|
+
## Endpoint Patterns Captured
|
|
350
|
+
${apiEndpoints?.slice(0, 20).map((e) => `- \`${e.method} ${e.urlPattern}\``).join('\n') || '- No API endpoints captured during crawl'}
|
|
351
|
+
|
|
352
|
+
## Test Implications
|
|
353
|
+
- **Locator strategy**: ${uiLibrary.includes('MUI') ? 'Use `getByRole()` — MUI generates dynamic class suffixes, CSS classes unreliable' : uiLibrary.includes('Tailwind') ? 'Use `data-testid` or semantic attributes — Tailwind classes are layout utilities, not component identifiers' : 'CSS classes may be stable for locators'}
|
|
354
|
+
- **Framework**: Use Playwright's \`waitForLoadState("networkidle")\` for SPA navigation
|
|
355
|
+
- **API mocking**: Intercept \`${apiStyle === 'GraphQL' ? '/graphql' : apiEndpoints?.[0]?.urlPattern.split('/{id}')[0] ?? '/api'}\` for integration tests
|
|
356
|
+
`;
|
|
357
|
+
const tags = [
|
|
358
|
+
uiLibrary.split(' ')[0].toLowerCase(),
|
|
359
|
+
apiStyle.toLowerCase(),
|
|
360
|
+
appType.includes('SPA') ? 'spa' : 'ssr',
|
|
361
|
+
];
|
|
362
|
+
return { content, summary: `Tech stack: ${uiLibrary}, ${apiStyle} API`, tags };
|
|
363
|
+
}
|
|
364
|
+
/** AUTH_SESSION — from network request patterns + redirect behavior */
|
|
365
|
+
function generateAuthSessionFile(screens, apiEndpoints) {
|
|
366
|
+
const endpoints = apiEndpoints ?? [];
|
|
367
|
+
// Detect auth endpoints
|
|
368
|
+
const hasOAuth = endpoints.some((e) => /\/(oauth|auth\/(google|github|facebook|microsoft|apple))/i.test(e.urlPattern));
|
|
369
|
+
const hasJWT = endpoints.some((e) => /\/(auth\/login|auth\/signin|auth\/token)/i.test(e.urlPattern));
|
|
370
|
+
const has2FA = endpoints.some((e) => /\/(totp|2fa|otp|verify|mfa)/i.test(e.urlPattern));
|
|
371
|
+
const hasRefresh = endpoints.some((e) => /\/(auth\/refresh|token\/refresh)/i.test(e.urlPattern));
|
|
372
|
+
const hasLogout = endpoints.some((e) => /\/(auth\/logout|signout|sign-out)/i.test(e.urlPattern));
|
|
373
|
+
// Detect auth-related screens from screen names
|
|
374
|
+
const screenNames = screens.map((s) => s.name.toLowerCase());
|
|
375
|
+
const hasLoginScreen = screenNames.some((n) => /login|signin/.test(n));
|
|
376
|
+
const hasRegisterScreen = screenNames.some((n) => /register|signup|sign-up/.test(n));
|
|
377
|
+
const hasForgotScreen = screenNames.some((n) => /forgot|reset|recover/.test(n));
|
|
378
|
+
// Classify auth type
|
|
379
|
+
// If no login screen was seen but protected screens exist → crawl used a pre-captured session
|
|
380
|
+
const hasProtectedScreens = screens.some((s) => s.url && !/^(\/|\/home|\/index)?$/.test(new URL(s.url).pathname ?? '/'));
|
|
381
|
+
const authType = hasOAuth
|
|
382
|
+
? 'OAuth 2.0 (social providers detected)'
|
|
383
|
+
: hasJWT
|
|
384
|
+
? 'JWT Bearer token'
|
|
385
|
+
: hasLoginScreen
|
|
386
|
+
? 'Session-based or JWT (login screen detected, auth endpoint pattern unclear)'
|
|
387
|
+
: (!hasLoginScreen && hasProtectedScreens)
|
|
388
|
+
? 'Session-based cookie auth (pre-captured via ZeTa session capture — no login screen visited during crawl)'
|
|
389
|
+
: 'Unknown (no login screen or auth endpoints detected)';
|
|
390
|
+
// Detect e-commerce context for targeted no-auth warning
|
|
391
|
+
const isEcommerce = screens.some((s) => /\/(shop|product|cart|checkout|woocommerce)/i.test(s.url ?? ''));
|
|
392
|
+
const noAuthWarning = !hasLoginScreen && !hasOAuth && !hasJWT && isEcommerce ? `
|
|
393
|
+
## ⚠️ E-Commerce Auth Note
|
|
394
|
+
No login/register screens were discovered during this crawl.
|
|
395
|
+
Typical WooCommerce stores have:
|
|
396
|
+
- \`/my-account/\` — Customer account dashboard
|
|
397
|
+
- \`/my-account/orders/\` — Order history
|
|
398
|
+
- \`/checkout/\` (logged-in flow) — Checkout with saved address
|
|
399
|
+
These pages may exist but weren't encountered during the automated crawl.
|
|
400
|
+
**Action required**: Manually verify these routes exist and add auth credentials in Project Settings → Credentials if needed for full coverage.
|
|
401
|
+
` : '';
|
|
402
|
+
// OAuth providers
|
|
403
|
+
const oauthProviders = endpoints
|
|
404
|
+
.filter((e) => /\/(oauth|auth)\/(google|github|facebook|microsoft|apple)/i.test(e.urlPattern))
|
|
405
|
+
.map((e) => {
|
|
406
|
+
const m = e.urlPattern.match(/\/(google|github|facebook|microsoft|apple)/i);
|
|
407
|
+
return m ? m[1] : null;
|
|
408
|
+
})
|
|
409
|
+
.filter(Boolean);
|
|
410
|
+
const content = `# Authentication & Session Model
|
|
411
|
+
> Auto-generated from network endpoint analysis during crawl.
|
|
412
|
+
> Verify manually: token storage key, session timeout value.
|
|
413
|
+
|
|
414
|
+
## Auth Type
|
|
415
|
+
**${authType}**
|
|
416
|
+
|
|
417
|
+
## Auth Endpoints Detected
|
|
418
|
+
${endpoints.filter((e) => /auth|login|logout|token|session|oauth/i.test(e.urlPattern))
|
|
419
|
+
.slice(0, 10)
|
|
420
|
+
.map((e) => `- \`${e.method} ${e.urlPattern}\``)
|
|
421
|
+
.join('\n') || '- None captured (AI login may have occurred before network interception started)'}
|
|
422
|
+
|
|
423
|
+
## Features Detected
|
|
424
|
+
- OAuth social providers: ${oauthProviders.length > 0 ? oauthProviders.join(', ') : 'none detected'}
|
|
425
|
+
- Two-Factor Authentication (2FA/TOTP): ${has2FA ? '✅ Detected' : '❌ Not detected'}
|
|
426
|
+
- Token refresh endpoint: ${hasRefresh ? '✅ Detected' : '❌ Not detected'}
|
|
427
|
+
- Logout endpoint: ${hasLogout ? '✅ Detected' : '❌ Not detected'}
|
|
428
|
+
|
|
429
|
+
## Screens Discovered
|
|
430
|
+
- Login screen: ${hasLoginScreen ? '✅' : '❌'}
|
|
431
|
+
- Registration screen: ${hasRegisterScreen ? '✅' : '❌'}
|
|
432
|
+
- Forgot/reset password: ${hasForgotScreen ? '✅' : '❌'}
|
|
433
|
+
|
|
434
|
+
## Test Preconditions
|
|
435
|
+
- All protected tests MUST call the login endpoint and store the response token/cookie.
|
|
436
|
+
- Recommended setup: \`beforeAll\` → call POST ${endpoints.find((e) => /auth\/login|signin/i.test(e.urlPattern))?.urlPattern ?? '/api/auth/login'} → store token in test context.
|
|
437
|
+
- For storageState: capture session once with Playwright codegen, store encrypted JSON in project settings.
|
|
438
|
+
- 2FA tests: require \`oneTimeCode\` parameter (see crawler auth configuration).
|
|
439
|
+
|
|
440
|
+
## Security Test Targets
|
|
441
|
+
- Test session fixation: verify token changes after login
|
|
442
|
+
- Test token expiry: use expired token → expect 401
|
|
443
|
+
${has2FA ? '- Test 2FA bypass: skip OTP step → expect 401\n- Test invalid OTP: try 000000 → expect specific error message' : ''}
|
|
444
|
+
- Test unauthorized routes: access protected screens without auth → expect redirect to /login
|
|
445
|
+
${noAuthWarning}`;
|
|
446
|
+
const tags = [
|
|
447
|
+
hasOAuth ? 'oauth' : hasJWT ? 'jwt' : 'session',
|
|
448
|
+
has2FA ? '2fa' : 'no-2fa',
|
|
449
|
+
hasRefresh ? 'refresh-token' : '',
|
|
450
|
+
isEcommerce ? 'e-commerce' : '',
|
|
451
|
+
].filter(Boolean);
|
|
452
|
+
return { content, summary: `Auth: ${authType}. 2FA: ${has2FA}. OAuth: ${oauthProviders.join(', ') || 'none'}.`, tags };
|
|
453
|
+
}
|
|
454
|
+
/** API_CONTRACT — from HAR-style endpoint capture during crawl */
|
|
455
|
+
function generateApiContractFile(apiEndpoints) {
|
|
456
|
+
const endpoints = apiEndpoints ?? [];
|
|
457
|
+
if (endpoints.length === 0) {
|
|
458
|
+
return {
|
|
459
|
+
content: `# API Contract\n\n> No API endpoints were captured during the crawl.\n> This may happen if:\n> 1. The app loads data before crawl navigation starts\n> 2. The app uses WebSockets (not intercepted by XHR listener)\n> 3. All data is server-rendered with no client-side API calls\n\nRe-crawl after authentication to capture protected API endpoints.`,
|
|
460
|
+
summary: 'No API endpoints captured during crawl.',
|
|
461
|
+
tags: [],
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
// Group by resource type
|
|
465
|
+
const groups = {};
|
|
466
|
+
for (const ep of endpoints) {
|
|
467
|
+
const parts = ep.urlPattern.split('/').filter(Boolean);
|
|
468
|
+
const resource = parts.find((p) => !/^(api|v\d+|rest|gql|graphql)$/i.test(p)) ?? 'misc';
|
|
469
|
+
(groups[resource] ??= []).push(ep);
|
|
470
|
+
}
|
|
471
|
+
const content = `# API Contract
|
|
472
|
+
> Auto-generated from network interception during crawl. ${endpoints.length} unique endpoint patterns captured.
|
|
473
|
+
> **Review and annotate**: add request/response schemas when available.
|
|
474
|
+
|
|
475
|
+
## Endpoints by Resource
|
|
476
|
+
|
|
477
|
+
${Object.entries(groups).map(([resource, eps]) => `### ${resource.charAt(0).toUpperCase() + resource.slice(1)}
|
|
478
|
+
${eps.map((e) => `- \`${e.method.padEnd(6)} ${e.urlPattern}\``).join('\n')}`).join('\n\n')}
|
|
479
|
+
|
|
480
|
+
## Full Endpoint List (${endpoints.length} patterns)
|
|
481
|
+
${endpoints.map((e) => `- \`${e.method} ${e.urlPattern}\``).join('\n')}
|
|
482
|
+
|
|
483
|
+
## Test Implications
|
|
484
|
+
- **API test setup**: Use Playwright \`page.route()\` or Supertest to intercept these endpoints
|
|
485
|
+
- **Contract tests**: Verify each endpoint returns expected status codes (200/201/204/400/401/403/404)
|
|
486
|
+
- **Integration test data**: Use \`{id}\` placeholder patterns for parameterized tests
|
|
487
|
+
- **Security tests**: Test each endpoint without auth → expect 401; with wrong role → expect 403
|
|
488
|
+
`;
|
|
489
|
+
return {
|
|
490
|
+
content,
|
|
491
|
+
summary: `${endpoints.length} API endpoints captured. Resources: ${Object.keys(groups).slice(0, 5).join(', ')}`,
|
|
492
|
+
tags: ['api', 'rest', ...Object.keys(groups).slice(0, 5)],
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
/** NAVIGATION_MAP — from screen URLs + flow graph */
|
|
496
|
+
function generateNavigationMapFile(screens) {
|
|
497
|
+
const screenById = new Map(screens.map((s) => [s.id, s]));
|
|
498
|
+
const routes = screens
|
|
499
|
+
.filter((s) => s.url)
|
|
500
|
+
.map((s) => {
|
|
501
|
+
const url = s.url;
|
|
502
|
+
let pathname = '/';
|
|
503
|
+
try {
|
|
504
|
+
pathname = new URL(url).pathname;
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
pathname = url;
|
|
508
|
+
}
|
|
509
|
+
// Classify auth requirement from URL patterns
|
|
510
|
+
const isProtected = /\/(dashboard|project|settings|admin|workspace|tests|suites|runs|reports|profile|team|billing)/.test(pathname);
|
|
511
|
+
const isAdmin = /\/admin/.test(pathname);
|
|
512
|
+
const isPublic = /\/(login|register|signup|forgot|reset|pricing|about|landing|$)/.test(pathname) || !isProtected;
|
|
513
|
+
return {
|
|
514
|
+
id: s.id,
|
|
515
|
+
pathname,
|
|
516
|
+
screenName: s.name,
|
|
517
|
+
protected: !isPublic,
|
|
518
|
+
adminOnly: isAdmin,
|
|
519
|
+
elementCount: s.elements.length,
|
|
520
|
+
};
|
|
521
|
+
});
|
|
522
|
+
// Build navigation flow graph from element transitionsToScreenId edges
|
|
523
|
+
const transitionEdges = [];
|
|
524
|
+
for (const s of screens) {
|
|
525
|
+
const targets = s.elements
|
|
526
|
+
.filter((e) => e.transitionsToScreenId)
|
|
527
|
+
.map((e) => screenById.get(e.transitionsToScreenId)?.name)
|
|
528
|
+
.filter((name) => Boolean(name));
|
|
529
|
+
const unique = [...new Set(targets)];
|
|
530
|
+
if (unique.length > 0) {
|
|
531
|
+
transitionEdges.push(`- **${s.name}** → ${unique.map((t) => `\`${t}\``).join(', ')}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const navFlowsSection = transitionEdges.length > 0
|
|
535
|
+
? `## Navigation Flows (BFS-discovered transitions)\n${transitionEdges.join('\n')}\n`
|
|
536
|
+
: '';
|
|
537
|
+
const content = `# Navigation Map & Routing
|
|
538
|
+
> Auto-generated from discovered screens during crawl. Auth classification is inferred — verify manually.
|
|
539
|
+
|
|
540
|
+
## Route Inventory (${routes.length} screens)
|
|
541
|
+
|
|
542
|
+
| Route | Screen | Protected | Admin |
|
|
543
|
+
|-------|--------|:---------:|:-----:|
|
|
544
|
+
${routes.map((r) => `| \`${r.pathname}\` | ${r.screenName} | ${r.protected ? '✅' : '❌'} | ${r.adminOnly ? '✅' : '❌'} |`).join('\n')}
|
|
545
|
+
|
|
546
|
+
## Navigation Groups
|
|
547
|
+
${groupRoutesBySection(routes).map(([section, rs]) => `### ${section}\n${rs.map((r) => `- \`${r.pathname}\` — ${r.screenName} (${r.elementCount} elements)`).join('\n')}`).join('\n\n')}
|
|
548
|
+
|
|
549
|
+
${navFlowsSection}## Test Implications
|
|
550
|
+
- **Auth tests**: Navigate directly to protected routes without auth → expect redirect to \`/login\`
|
|
551
|
+
- **Role tests**: Navigate to admin routes as non-admin → expect 403 or redirect
|
|
552
|
+
- **Journey tests**: Follow the navigation flow from screen discovery (see flow graph in DB)
|
|
553
|
+
- **Deep link tests**: Navigate directly to \`/path?next=<protected>\` → verify next param respected after login
|
|
554
|
+
`;
|
|
555
|
+
return {
|
|
556
|
+
content,
|
|
557
|
+
summary: `${routes.length} routes: ${routes.filter((r) => r.protected).length} protected, ${routes.filter((r) => r.adminOnly).length} admin-only`,
|
|
558
|
+
tags: ['routing', 'navigation', 'auth-guards'],
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
/** LOCATOR_STRATEGY — from data-testid presence + ARIA attribute analysis */
|
|
562
|
+
function generateLocatorStrategyFile(screens) {
|
|
563
|
+
const allElements = screens.flatMap((s) => s.elements.map((e) => ({ ...e, screenName: s.name })));
|
|
564
|
+
const hasRequired = allElements.filter((e) => {
|
|
565
|
+
try {
|
|
566
|
+
return JSON.parse(e.notes ?? '{}').required === 'true';
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
const inputElements = allElements.filter((e) => ['email', 'password', 'text', 'tel', 'date', 'number', 'url', 'file', 'textarea', 'select'].includes(e.role));
|
|
573
|
+
// Semantic role coverage: elements with typed roles (button, email, select, etc.)
|
|
574
|
+
const semanticRoleCount = allElements.filter((e) => ['button', 'submit', 'email', 'tel', 'search', 'select', 'checkbox', 'radio'].includes(e.role ?? '')).length;
|
|
575
|
+
// Locator hint coverage: elements with data-testid / aria-label / placeholder / label
|
|
576
|
+
const withLocatorHints = allElements.filter((e) => {
|
|
577
|
+
const notes = (() => { try {
|
|
578
|
+
return JSON.parse(e.notes ?? '{}');
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
return {};
|
|
582
|
+
} })();
|
|
583
|
+
return !!(notes.dataTestid || notes.ariaLabel || notes.placeholder || e.meaning);
|
|
584
|
+
}).length;
|
|
585
|
+
// Detect fragile patterns
|
|
586
|
+
const fragileByNthChild = allElements.filter((e) => e.meaning.match(/^\d+$/) || e.meaning.includes('item ')).length;
|
|
587
|
+
const content = `# Locator Strategy
|
|
588
|
+
> Auto-generated from element analysis. Prescribes the safest locator strategy for this application.
|
|
589
|
+
|
|
590
|
+
## Recommended Priority Order
|
|
591
|
+
1. **\`data-testid\`** (most stable — never changes, 0 false positives)
|
|
592
|
+
- Locator hint coverage: **${Math.round((withLocatorHints / Math.max(allElements.length, 1)) * 100)}%** of elements have data-testid / aria-label / placeholder / label
|
|
593
|
+
- If missing: ask developers to add \`data-testid="[screen]-[element]-[type]"\` to key elements
|
|
594
|
+
2. **ARIA role + accessible name** (excellent stability, semantic)
|
|
595
|
+
- Example: \`getByRole('button', { name: 'Sign In' })\`
|
|
596
|
+
- Semantic role coverage: **${Math.round((semanticRoleCount / Math.max(allElements.length, 1)) * 100)}%** of elements have typed roles (button, email, select, etc.)
|
|
597
|
+
3. **Label text** (stable, affected by i18n changes)
|
|
598
|
+
- Example: \`getByLabel('Email address')\`
|
|
599
|
+
- Coverage: **${Math.round((inputElements.length / Math.max(allElements.length, 1)) * 100)}%** of form inputs
|
|
600
|
+
4. **Placeholder text** (fragile — UI copy changes break tests)
|
|
601
|
+
5. **CSS class** (AVOID for component libraries — generated dynamic suffixes)
|
|
602
|
+
|
|
603
|
+
## Elements by Role (${allElements.length} total)
|
|
604
|
+
${elementRoleSummary(allElements)}
|
|
605
|
+
|
|
606
|
+
## Required Fields (${hasRequired.length})
|
|
607
|
+
${hasRequired.slice(0, 10).map((e) => `- [${e.screenName}] \`${e.meaning}\` (${e.role}) — required, test empty submission`).join('\n') || '- None detected with required attribute'}
|
|
608
|
+
|
|
609
|
+
## Fragile Locator Risks
|
|
610
|
+
- Elements with numeric-only meanings: ${fragileByNthChild} (likely nth-child candidates — high brittleness)
|
|
611
|
+
- Recommend: add \`data-testid\` to any element where the locator would use \`nth()\` or \`first()\`
|
|
612
|
+
|
|
613
|
+
## Never Use
|
|
614
|
+
- Dynamic CSS class suffixes: \`MuiButton-root-jss123\`, \`css-1234abc\`
|
|
615
|
+
- XPath with positional predicates: \`//button[2]\`
|
|
616
|
+
- \`nth-child\` without a parent locator constraint
|
|
617
|
+
`;
|
|
618
|
+
return {
|
|
619
|
+
content,
|
|
620
|
+
summary: `${allElements.length} elements analyzed. ${hasRequired.length} required fields. Semantic role coverage: ${Math.round((semanticRoleCount / Math.max(allElements.length, 1)) * 100)}%. Locator hint coverage: ${Math.round((withLocatorHints / Math.max(allElements.length, 1)) * 100)}%.`,
|
|
621
|
+
tags: ['locators', 'selectors', 'stability', 'data-testid'],
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
/** PERFORMANCE_BASELINE — from Playwright performance metrics during crawl */
|
|
625
|
+
function generatePerformanceBaselineFile(screens, metrics) {
|
|
626
|
+
if (!metrics || metrics.length === 0) {
|
|
627
|
+
const content = `# Performance Baseline
|
|
628
|
+
> No performance metrics captured during this crawl.
|
|
629
|
+
> To enable: pass \`performanceMetrics\` from Playwright \`page.evaluate(() => performance.getEntriesByType('navigation'))\` after each page load.
|
|
630
|
+
|
|
631
|
+
## Expected Metrics to Capture
|
|
632
|
+
- LCP (Largest Contentful Paint) — should be < 2500ms
|
|
633
|
+
- FCP (First Contentful Paint) — should be < 1800ms
|
|
634
|
+
- TTFB (Time to First Byte) — should be < 800ms
|
|
635
|
+
|
|
636
|
+
## Manual Baseline
|
|
637
|
+
For now, performance tests should assert:
|
|
638
|
+
- Page loads within **3 seconds** on desktop
|
|
639
|
+
- Page loads within **5 seconds** on mobile (emulated 4G)
|
|
640
|
+
- No single API call exceeds **2 seconds** response time
|
|
641
|
+
|
|
642
|
+
## Screens to Prioritize for Performance Testing
|
|
643
|
+
${screens.slice(0, 5).map((s) => `- ${s.name} (${s.elements.length} elements — heavier page)`).join('\n')}
|
|
644
|
+
`;
|
|
645
|
+
return { content, summary: 'No performance metrics captured.', tags: ['performance', 'lcp', 'cwv'] };
|
|
646
|
+
}
|
|
647
|
+
// Core Web Vitals thresholds (web.dev): LCP 2500/4000ms, CLS 0.1/0.25, FCP 1800/3000ms.
|
|
648
|
+
const rateScreen = (m) => {
|
|
649
|
+
const lcpBad = m.lcp != null && m.lcp >= 4000;
|
|
650
|
+
const lcpWarn = m.lcp != null && m.lcp >= 2500;
|
|
651
|
+
const clsBad = m.cls != null && m.cls >= 0.25;
|
|
652
|
+
const clsWarn = m.cls != null && m.cls >= 0.1;
|
|
653
|
+
const fcpWarn = m.fcp != null && m.fcp >= 1800;
|
|
654
|
+
if (m.lcp == null && m.cls == null && m.fcp == null)
|
|
655
|
+
return '—';
|
|
656
|
+
if (lcpBad || clsBad)
|
|
657
|
+
return '❌ Poor';
|
|
658
|
+
if (lcpWarn || clsWarn || fcpWarn)
|
|
659
|
+
return '⚠️ Needs Improvement';
|
|
660
|
+
return '✅ Good';
|
|
661
|
+
};
|
|
662
|
+
const content = `# Performance Baseline
|
|
663
|
+
> Measured during crawl: LCP/CLS via buffered PerformanceObserver, FCP/TTFB via Navigation/Paint Timing. Updated after each crawl.
|
|
664
|
+
|
|
665
|
+
## Per-Screen Metrics
|
|
666
|
+
|
|
667
|
+
| Screen | LCP (ms) | FCP (ms) | TTFB (ms) | CLS | Status |
|
|
668
|
+
|--------|:--------:|:--------:|:---------:|:---:|--------|
|
|
669
|
+
${metrics.map((m) => `| ${m.screenName} | ${m.lcp != null ? m.lcp : 'N/A ⚠️'} | ${m.fcp ?? '—'} | ${m.ttfb ?? '—'} | ${m.cls ?? '—'} | ${rateScreen(m)} |`).join('\n')}
|
|
670
|
+
|
|
671
|
+
Status thresholds (Core Web Vitals): LCP < 2500ms good / ≥ 4000ms poor · CLS < 0.1 good / ≥ 0.25 poor · FCP < 1800ms good.
|
|
672
|
+
|
|
673
|
+
## Regression Thresholds (auto-enforced in performance TCs)
|
|
674
|
+
- Flag if LCP increases by **> 500ms** vs this baseline
|
|
675
|
+
- Flag if TTFB exceeds **800ms** (CDN/SSR degradation signal)
|
|
676
|
+
- Flag if CLS increases by **> 0.05** vs this baseline
|
|
677
|
+
- Flag if any bundle increases by **> 10%** (detect dependency bloat)
|
|
678
|
+
|
|
679
|
+
## Slowest Screens (highest test priority)
|
|
680
|
+
${[...metrics].sort((a, b) => (b.lcp ?? b.fcp ?? 0) - (a.lcp ?? a.fcp ?? 0)).slice(0, 3).map((m) => `- ${m.screenName}: LCP ${m.lcp ?? 'unknown'}ms, FCP ${m.fcp ?? 'unknown'}ms`).join('\n')}
|
|
681
|
+
`;
|
|
682
|
+
const allLcpMissing = metrics.every((m) => !m.lcp);
|
|
683
|
+
const lcpWarning = allLcpMissing ? `
|
|
684
|
+
## ⚠️ LCP Data Missing
|
|
685
|
+
Largest Contentful Paint (LCP) was not measured during this crawl.
|
|
686
|
+
For e-commerce sites, LCP is a critical conversion metric — every 100ms delay reduces sales.
|
|
687
|
+
Product images and hero banners are typically the LCP element.
|
|
688
|
+
|
|
689
|
+
**To capture LCP**: Re-run the crawl with performance metrics enabled in Project Settings.
|
|
690
|
+
**Target**: LCP < 2500ms (good), < 4000ms (needs improvement), > 4000ms (poor)
|
|
691
|
+
|
|
692
|
+
Current FCP readings suggest potential issues:
|
|
693
|
+
${metrics
|
|
694
|
+
.filter((m) => (m.fcp ?? 0) > 4000)
|
|
695
|
+
.slice(0, 5)
|
|
696
|
+
.map((m) => `- **${m.screenName}**: ${m.fcp}ms FCP — likely slow LCP`)
|
|
697
|
+
.join('\n') || '- No screens with FCP > 4000ms detected'}
|
|
698
|
+
` : '';
|
|
699
|
+
return {
|
|
700
|
+
content: content + lcpWarning,
|
|
701
|
+
summary: `Performance baseline: ${metrics.length} screens measured. Baseline captured for regression detection.${allLcpMissing ? ' LCP data missing.' : ''}`,
|
|
702
|
+
tags: ['performance', 'lcp', 'fcp', 'cwv', 'baseline'],
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
// ─── TIER 2: Domain Intelligence (LLM) ───────────────────────────────────────
|
|
706
|
+
async function generateBusinessDomainFile(summary, screens, askFn) {
|
|
707
|
+
const screenNames = screens.map((s) => s.name).join(', ');
|
|
708
|
+
const screenUrls = screens.filter((s) => s.url).slice(0, 15).map((s) => s.url).join(', ');
|
|
709
|
+
const content = await askFn(`You are a senior business analyst reviewing a web application.
|
|
710
|
+
Identify the application's domain, user types, user roles, core workflows, and key business rules.
|
|
711
|
+
Only state what is clearly evident from screen names, URLs, and element purposes.
|
|
712
|
+
Do NOT guess or make up features. If uncertain, say "unclear from crawl data."
|
|
713
|
+
|
|
714
|
+
Output Markdown with these sections:
|
|
715
|
+
## Application Type & Domain
|
|
716
|
+
## Primary User Roles (from screens)
|
|
717
|
+
## Core User Journeys (observable from screen flow)
|
|
718
|
+
## Key Business Rules (observable from element constraints and screen flow)
|
|
719
|
+
## Domain Vocabulary (key terms used in the UI)`, `Screen names discovered: ${screenNames}
|
|
720
|
+
Screen URLs: ${screenUrls}
|
|
721
|
+
|
|
722
|
+
Element summary (representative sample):
|
|
723
|
+
${summary.slice(0, 2000)}
|
|
724
|
+
|
|
725
|
+
Write a BUSINESS_DOMAIN skill file (Markdown). Be specific and factual. Max 400 words.`);
|
|
726
|
+
const tags = extractTags(content, ['e-commerce', 'saas', 'healthcare', 'finance',
|
|
727
|
+
'crm', 'hrm', 'dashboard', 'checkout', 'booking', 'marketplace', 'analytics', 'qa', 'testing']);
|
|
728
|
+
return {
|
|
729
|
+
content,
|
|
730
|
+
summary: `Application domain and functional classification from crawl analysis.`,
|
|
731
|
+
tags,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
async function generateDataModelFile(_summary, screens, askFn, apiEndpoints) {
|
|
735
|
+
// Extract all input elements — include those without explicit constraints (email role implies email format, etc.)
|
|
736
|
+
const INPUT_FIELD_ROLES = ['email', 'password', 'text', 'tel', 'date', 'number', 'url', 'file', 'textarea', 'select', 'search', 'checkbox', 'radio', 'rich-text'];
|
|
737
|
+
const constrainedElements = screens
|
|
738
|
+
.flatMap((s) => s.elements.map((e) => ({ ...e, screenName: s.name })))
|
|
739
|
+
.filter((e) => {
|
|
740
|
+
if (INPUT_FIELD_ROLES.includes(e.role))
|
|
741
|
+
return true;
|
|
742
|
+
try {
|
|
743
|
+
const n = JSON.parse(e.notes ?? '{}');
|
|
744
|
+
return Object.keys(n).some((k) => ['pattern', 'min', 'max', 'minLength', 'maxLength', 'required', 'accept', 'multiple'].includes(k));
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
const constraintList = constrainedElements
|
|
751
|
+
.slice(0, 40)
|
|
752
|
+
.map((e) => {
|
|
753
|
+
try {
|
|
754
|
+
const attrs = JSON.parse(e.notes ?? '{}');
|
|
755
|
+
const constraints = Object.entries(attrs)
|
|
756
|
+
.filter(([k]) => k !== 'cssClass')
|
|
757
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
758
|
+
.join(', ');
|
|
759
|
+
return `- [${e.screenName}] ${e.meaning} (${e.role}): ${constraints}`;
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
})
|
|
765
|
+
.filter(Boolean)
|
|
766
|
+
.join('\n');
|
|
767
|
+
// Infer entity names from screen names + API endpoints
|
|
768
|
+
const entityNames = [
|
|
769
|
+
...screens.map((s) => s.name.replace(/\s+list|s$/i, '')),
|
|
770
|
+
...(apiEndpoints ?? []).map((e) => {
|
|
771
|
+
const parts = e.urlPattern.split('/').filter((p) => p && !p.startsWith('{') && !/^(api|v\d+|rest)$/i.test(p));
|
|
772
|
+
return parts[parts.length - 1] ?? '';
|
|
773
|
+
}),
|
|
774
|
+
].filter(Boolean);
|
|
775
|
+
const uniqueEntities = [...new Set(entityNames.map((n) => n.charAt(0).toUpperCase() + n.slice(1)))].slice(0, 10);
|
|
776
|
+
const content = await askFn(`You are a data architect inferring the application's data model from UI evidence.
|
|
777
|
+
ONLY use the field constraints and entity names provided. Do not invent fields that aren't shown.
|
|
778
|
+
Output Markdown with these sections:
|
|
779
|
+
## Discovered Entities (name + fields with constraints)
|
|
780
|
+
## Field Constraint Summary (table: field, type, required, min, max, pattern)
|
|
781
|
+
## Validation Rules (from HTML5 attributes — ground test boundary values in these)
|
|
782
|
+
## Test Implications (which boundary values and equivalence classes these constraints define)`, `Inferred entities from screen names + API paths:
|
|
783
|
+
${uniqueEntities.join(', ')}
|
|
784
|
+
|
|
785
|
+
Field constraints extracted from DOM (HTML5 attributes):
|
|
786
|
+
${constraintList || '- No constraints extracted yet — check crawler HTML5 attribute extraction'}
|
|
787
|
+
|
|
788
|
+
Write a DATA_MODEL skill file (Markdown). Be precise. These constraints DIRECTLY determine test boundary values. Max 400 words.`);
|
|
789
|
+
return {
|
|
790
|
+
content,
|
|
791
|
+
summary: `Data model with ${constrainedElements.length} field constraints. Entities: ${uniqueEntities.slice(0, 5).join(', ')}`,
|
|
792
|
+
tags: ['data-model', 'validation', 'constraints', 'boundary-values'],
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
async function generateScreenUnderstandingFile(summary, screens, _askFn, askVisionFn, screenshots) {
|
|
796
|
+
const PRIMARY_LIMIT = 40;
|
|
797
|
+
const screenList = screens.slice(0, PRIMARY_LIMIT).map((s) => {
|
|
798
|
+
const elements = s.elements.slice(0, 10).map((e) => e.meaning ?? e.role).filter(Boolean);
|
|
799
|
+
return `- **${s.name}** (${s.url ?? 'no URL'}): ${elements.join(', ') || 'no elements captured'}`;
|
|
800
|
+
}).join('\n');
|
|
801
|
+
// Additional screens condensed table (GAP-SF-10)
|
|
802
|
+
const overflowScreens = screens.slice(PRIMARY_LIMIT);
|
|
803
|
+
const overflowSection = overflowScreens.length > 0
|
|
804
|
+
? `\n\n## Additional Screens (${overflowScreens.length} more)\n\n| Screen | URL | Elements |\n|---|---|---|\n${overflowScreens.map((s) => {
|
|
805
|
+
let pathname = s.url ?? 'N/A';
|
|
806
|
+
try {
|
|
807
|
+
pathname = new URL(s.url).pathname;
|
|
808
|
+
}
|
|
809
|
+
catch { /* keep as-is */ }
|
|
810
|
+
return `| ${s.name} | \`${pathname}\` | ${s.elements.length} |`;
|
|
811
|
+
}).join('\n')}`
|
|
812
|
+
: '';
|
|
813
|
+
const content = await askVisionFn(`You are a senior QA architect documenting a web application for AI-assisted test generation.
|
|
814
|
+
Write a SCREEN_UNDERSTANDING skill file that gives a QA engineer a clear picture of each screen's
|
|
815
|
+
purpose, user roles, key interactions, pre/post conditions, and critical test focus areas.
|
|
816
|
+
Only describe what is evident from the data provided — do not invent features.
|
|
817
|
+
Output Markdown with an H2 "## Screen Inventory" overview table, then H3 sections per screen.`, `## Application Overview
|
|
818
|
+
${summary.slice(0, 1500)}
|
|
819
|
+
|
|
820
|
+
## Screen List (${Math.min(screens.length, PRIMARY_LIMIT)} of ${screens.length} screens)
|
|
821
|
+
${screenList}
|
|
822
|
+
|
|
823
|
+
## Task
|
|
824
|
+
Write a SCREEN_UNDERSTANDING skill file (Markdown). For each screen include:
|
|
825
|
+
- **Purpose**: one sentence
|
|
826
|
+
- **User roles**: who accesses this screen
|
|
827
|
+
- **Key interactions**: bullet list of primary actions
|
|
828
|
+
- **Pre-conditions**: what state must be true before arriving here
|
|
829
|
+
- **Test focus**: edge cases, error states, and regression risks
|
|
830
|
+
|
|
831
|
+
Keep each screen section concise (4–6 bullet points max). Start with a summary table.`, screenshots);
|
|
832
|
+
return {
|
|
833
|
+
content: content + overflowSection,
|
|
834
|
+
summary: `Per-screen documentation for ${screens.length} screens covering purpose, roles, interactions, and test focus.`,
|
|
835
|
+
tags: ['screens', 'user-journeys', 'ux', 'test-focus'],
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
// ─── TIER 3: Behavioral Intelligence (LLM + vision) ──────────────────────────
|
|
839
|
+
async function generateInteractionPatternsFile(summary, screens, _askFn, askVisionFn, screenshots) {
|
|
840
|
+
// Extract ARIA patterns that reveal interaction conventions
|
|
841
|
+
const ariaPatterns = screens
|
|
842
|
+
.flatMap((s) => s.elements.map((e) => {
|
|
843
|
+
try {
|
|
844
|
+
const n = JSON.parse(e.notes ?? '{}');
|
|
845
|
+
return { screenName: s.name, meaning: e.meaning, role: e.role, ...n };
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
}))
|
|
851
|
+
.filter(Boolean)
|
|
852
|
+
.filter((e) => e.ariaHasPopup || e.ariaExpanded || e.ariaDisabled || e.autocomplete)
|
|
853
|
+
.slice(0, 20)
|
|
854
|
+
.map((e) => `- [${e.screenName}] ${e.meaning}: ${Object.entries(e).filter(([k]) => k.startsWith('aria')).map(([k, v]) => `${k}=${v}`).join(', ')}`)
|
|
855
|
+
.join('\n');
|
|
856
|
+
const visionHint = screenshots.length > 0
|
|
857
|
+
? `\n\nYou have ${screenshots.length} screenshot(s). From them, identify:
|
|
858
|
+
1. Where error messages appear (below field / in a toast / in a banner / in a modal?)
|
|
859
|
+
2. Where loading states appear (button spinner / skeleton screen / progress bar in header?)
|
|
860
|
+
3. How success is communicated (redirect / inline success / toast notification?)
|
|
861
|
+
4. Navigation pattern (sidebar / top nav / tab bar?)
|
|
862
|
+
5. Form layout (single column / two-column / wizard/steps?)`
|
|
863
|
+
: '';
|
|
864
|
+
const content = await askVisionFn(`You are a QA engineer documenting behavioral conventions of a web application.
|
|
865
|
+
These conventions apply to EVERY test case as preconditions and assertions.
|
|
866
|
+
Be precise and factual. Only document what is visually or structurally observable.
|
|
867
|
+
Output Markdown with these sections:
|
|
868
|
+
## Error Display Convention (WHERE and HOW errors appear)
|
|
869
|
+
## Loading State Convention (what happens while data loads)
|
|
870
|
+
## Form Submission Pattern (validation timing, success/failure handling)
|
|
871
|
+
## Navigation Convention (primary nav structure and behavior)
|
|
872
|
+
## Notification/Toast Style (types, duration, position)
|
|
873
|
+
## Keyboard Navigation (Tab order, Escape behavior, Enter to submit)`, `Cross-screen element patterns:
|
|
874
|
+
${summary.slice(0, 1500)}
|
|
875
|
+
|
|
876
|
+
ARIA interaction patterns detected:
|
|
877
|
+
${ariaPatterns || '- No ARIA interaction patterns captured'}
|
|
878
|
+
${visionHint}
|
|
879
|
+
|
|
880
|
+
Write an INTERACTION_PATTERNS skill file (Markdown). These patterns ground state transition tests. Max 400 words.`, screenshots);
|
|
881
|
+
return {
|
|
882
|
+
content,
|
|
883
|
+
summary: `Behavioral conventions: error display, loading states, form submission, navigation patterns.`,
|
|
884
|
+
tags: ['interactions', 'error-handling', 'loading-states', 'form-patterns'],
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
async function generateErrorCatalogueFile(summary, screens, _askFn, askVisionFn, screenshots, probeObs) {
|
|
888
|
+
const visionHint = screenshots.length > 0
|
|
889
|
+
? `\n\nYou have ${screenshots.length} screenshot(s). Look for any visible error messages, validation messages, warning banners, or empty-state messages in the screenshots. Extract the EXACT text of any error messages you can read.`
|
|
890
|
+
: '';
|
|
891
|
+
const validationFacts = [];
|
|
892
|
+
const successFacts = [];
|
|
893
|
+
if (probeObs?.size) {
|
|
894
|
+
for (const s of screens) {
|
|
895
|
+
for (const p of (probeObs.get(s.id) ?? [])) {
|
|
896
|
+
if (p.observationType === 'VALIDATION_ERROR') {
|
|
897
|
+
validationFacts.push(`- [${s.name}] Field "${p.elementMeaning}": trigger="${p.triggeredBy}" → "${(p.observedText ?? '').slice(0, 150)}"`);
|
|
898
|
+
}
|
|
899
|
+
else if (p.observationType === 'SUCCESS_MESSAGE') {
|
|
900
|
+
successFacts.push(`- [${s.name}] After "${p.triggeredBy}": "${(p.observedText ?? '').slice(0, 150)}"`);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
const groundTruthHint = [
|
|
906
|
+
validationFacts.length > 0
|
|
907
|
+
? `\n\n## GROUND-TRUTH Validation Errors (observed by interactive probe — COPY VERBATIM, do not paraphrase):\n${validationFacts.join('\n')}`
|
|
908
|
+
: '',
|
|
909
|
+
successFacts.length > 0
|
|
910
|
+
? `\n\n## GROUND-TRUTH Success Messages (observed by interactive probe — include verbatim in "Empty State Messages" or a new "Success Messages" section):\n${successFacts.join('\n')}`
|
|
911
|
+
: '',
|
|
912
|
+
].join('');
|
|
913
|
+
const requiredFields = screens
|
|
914
|
+
.flatMap((s) => s.elements.map((e) => {
|
|
915
|
+
try {
|
|
916
|
+
const n = JSON.parse(e.notes ?? '{}');
|
|
917
|
+
return n.required === 'true' ? `[${s.name}] ${e.meaning} (${e.role})` : null;
|
|
918
|
+
}
|
|
919
|
+
catch {
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
}))
|
|
923
|
+
.filter(Boolean)
|
|
924
|
+
.slice(0, 20)
|
|
925
|
+
.join('\n');
|
|
926
|
+
const content = await askVisionFn(`You are a QA engineer cataloguing error messages for a web application.
|
|
927
|
+
Your goal: document the EXACT error text that tests should assert against.
|
|
928
|
+
Without exact text, tests assert "an error exists" — with exact text, tests assert "Email is required" — far more reliable.
|
|
929
|
+
Output Markdown with these sections:
|
|
930
|
+
## Validation Error Messages (per field, exact text if known)
|
|
931
|
+
## API/Server Error Messages (HTTP status → displayed message)
|
|
932
|
+
## Empty State Messages (what shows when a list is empty)
|
|
933
|
+
## Warning Messages (non-blocking, informational)
|
|
934
|
+
## Error Display Location (which field shows which error)`, `Required fields that need error message documentation:
|
|
935
|
+
${requiredFields || '- No required fields detected with certainty'}
|
|
936
|
+
|
|
937
|
+
Screen context:
|
|
938
|
+
${summary.slice(0, 1000)}
|
|
939
|
+
${groundTruthHint}
|
|
940
|
+
${visionHint}
|
|
941
|
+
|
|
942
|
+
Write an ERROR_CATALOGUE skill file (Markdown). Use "UNKNOWN — verify manually" for messages you cannot determine. Max 400 words.`, screenshots);
|
|
943
|
+
return {
|
|
944
|
+
content,
|
|
945
|
+
summary: `Error message catalogue for test assertions. Covers validation, API errors, and empty states.`,
|
|
946
|
+
tags: ['errors', 'validation', 'error-messages', 'assertions'],
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
// ─── TIER 4: Structural Intelligence (LLM + vision) ──────────────────────────
|
|
950
|
+
async function generateVisualBaselineFile(summary, _screens, _askFn, askVisionFn, screenshots) {
|
|
951
|
+
const visionHint = screenshots.length > 0
|
|
952
|
+
? `\n\nYou have ${screenshots.length} screenshot(s) of the application.
|
|
953
|
+
From them, extract:
|
|
954
|
+
1. **Color palette** — primary, secondary, background, text, danger, success, warning colors (use hex or RGB)
|
|
955
|
+
2. **Typography** — font family for headings and body text, approximate weights/sizes
|
|
956
|
+
3. **Layout structure** — sidebar width, header height, content area, footer
|
|
957
|
+
4. **Dark/light mode** — which mode is active in the screenshot?
|
|
958
|
+
5. **Design style** — glassmorphism, flat, material, neo-brutalist, or minimal?
|
|
959
|
+
6. **Contrast ratios** — flag any text that appears low-contrast (estimate visually)`
|
|
960
|
+
: `\n\nNo screenshots available. Document only what can be inferred from element patterns and CSS classes.`;
|
|
961
|
+
const content = await askVisionFn(`You are a UI/UX specialist and accessibility auditor reviewing a web application.
|
|
962
|
+
Extract factual visual design information ONLY from what is directly observable.
|
|
963
|
+
Do not guess or assume. If a color cannot be confirmed, say "NOT CONFIRMED — requires manual verification."
|
|
964
|
+
Output Markdown with these sections:
|
|
965
|
+
## Color Palette (primary, secondary, background, text, status colors with hex values)
|
|
966
|
+
## Typography (font families, size/weight hierarchy)
|
|
967
|
+
## Layout Structure (per screen type: auth, dashboard, form, list)
|
|
968
|
+
## Contrast Ratios (flag any that appear below 4.5:1 for AA compliance)
|
|
969
|
+
## Design System (component library visual style indicators)
|
|
970
|
+
## Dark/Light Mode Support`, `Element summary (design pattern signals):
|
|
971
|
+
${summary.slice(0, 1000)}
|
|
972
|
+
${visionHint}
|
|
973
|
+
|
|
974
|
+
Write a VISUAL_BASELINE skill file (Markdown). Max 400 words. State confidence level per item (HIGH/MEDIUM/LOW).`, screenshots);
|
|
975
|
+
return {
|
|
976
|
+
content,
|
|
977
|
+
summary: `Visual design baseline: color palette, typography, layout structure, contrast ratios.`,
|
|
978
|
+
tags: ['design-system', 'colors', 'typography', 'accessibility', 'wcag'],
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
async function generateComponentLibraryFile(summary, screens, techStackContent, askFn) {
|
|
982
|
+
const componentPatterns = identifyComponentPatterns(screens);
|
|
983
|
+
// Extract CSS class-based component hints
|
|
984
|
+
const cssComponentHints = screens
|
|
985
|
+
.flatMap((s) => s.elements.map((e) => {
|
|
986
|
+
try {
|
|
987
|
+
const cls = JSON.parse(e.notes ?? '{}').cssClass;
|
|
988
|
+
return cls ? `${cls} (on ${s.name})` : null;
|
|
989
|
+
}
|
|
990
|
+
catch {
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
}))
|
|
994
|
+
.filter(Boolean)
|
|
995
|
+
.slice(0, 20)
|
|
996
|
+
.join(', ');
|
|
997
|
+
// Find complex single-screen components (high element-count unique patterns)
|
|
998
|
+
const roleFrequency = new Map();
|
|
999
|
+
for (const s of screens) {
|
|
1000
|
+
const rolesOnScreen = new Set(s.elements.map((e) => e.role));
|
|
1001
|
+
for (const role of rolesOnScreen) {
|
|
1002
|
+
const entry = roleFrequency.get(role) ?? { screens: [], count: 0 };
|
|
1003
|
+
entry.screens.push(s.name);
|
|
1004
|
+
entry.count++;
|
|
1005
|
+
roleFrequency.set(role, entry);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
const uniqueHeavyComponents = screens
|
|
1009
|
+
.filter((s) => s.elements.length >= (parseInt(process.env.SKILL_FILE_MIN_ELEMENTS ?? '') || 10))
|
|
1010
|
+
.map((s) => `- **${s.name}**: ${s.elements.length} elements`)
|
|
1011
|
+
.slice(0, 5)
|
|
1012
|
+
.join('\n');
|
|
1013
|
+
const content = await askFn(`You are a QA engineer documenting reusable UI component behaviors.
|
|
1014
|
+
Primary section: components that appear on MULTIPLE screens (highest regression risk).
|
|
1015
|
+
Secondary section "Screen-Specific Components": complex components that appear on only one screen but have high element count — these also need test coverage.
|
|
1016
|
+
For each component: document its name, screens it appears on, default state, key behaviors, and edge cases to test.
|
|
1017
|
+
Output Markdown with one section per component. Use the CSS class hints to identify the component library.`, `Tech stack context:
|
|
1018
|
+
${techStackContent.slice(0, 500)}
|
|
1019
|
+
|
|
1020
|
+
Recurring role patterns across screens:
|
|
1021
|
+
${componentPatterns}
|
|
1022
|
+
|
|
1023
|
+
CSS class component hints:
|
|
1024
|
+
${cssComponentHints || 'No CSS class hints available'}
|
|
1025
|
+
|
|
1026
|
+
Complex single-screen areas (high element count — may contain specialized components):
|
|
1027
|
+
${uniqueHeavyComponents || 'None detected'}
|
|
1028
|
+
|
|
1029
|
+
Screen element summary:
|
|
1030
|
+
${summary.slice(0, 1500)}
|
|
1031
|
+
|
|
1032
|
+
Write a COMPONENT_LIBRARY skill file (Markdown). Document multi-screen components in main sections, then add a "## Screen-Specific Components" section for complex unique widgets. Max 600 words.`);
|
|
1033
|
+
const tags = extractTags(content, ['table', 'modal', 'form', 'navigation', 'sidebar',
|
|
1034
|
+
'dropdown', 'pagination', 'search', 'filter', 'tabs', 'accordion', 'toast', 'dialog', 'data-grid']);
|
|
1035
|
+
return {
|
|
1036
|
+
content,
|
|
1037
|
+
summary: `Reusable component library: ${tags.slice(0, 5).join(', ')} identified.`,
|
|
1038
|
+
tags,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Derive concrete valid/invalid example values and state descriptions from
|
|
1043
|
+
* HTML5 constraint attributes — deterministic, no LLM. This is what fills the
|
|
1044
|
+
* Valid/Invalid Example columns that used to come back as "-".
|
|
1045
|
+
*/
|
|
1046
|
+
function deriveConstraintExamples(role, expectedData, attrs) {
|
|
1047
|
+
const required = attrs.required === 'true';
|
|
1048
|
+
const minLength = attrs.minLength ? parseInt(attrs.minLength, 10) : undefined;
|
|
1049
|
+
const maxLength = attrs.maxLength ? parseInt(attrs.maxLength, 10) : undefined;
|
|
1050
|
+
const min = attrs.min !== undefined && attrs.min !== '' ? Number(attrs.min) : undefined;
|
|
1051
|
+
const max = attrs.max !== undefined && attrs.max !== '' ? Number(attrs.max) : undefined;
|
|
1052
|
+
const step = attrs.step ? Number(attrs.step) : undefined;
|
|
1053
|
+
const constraintParts = [];
|
|
1054
|
+
if (required)
|
|
1055
|
+
constraintParts.push('required');
|
|
1056
|
+
if (attrs.pattern)
|
|
1057
|
+
constraintParts.push(`pattern: \`${attrs.pattern}\``);
|
|
1058
|
+
if (minLength !== undefined)
|
|
1059
|
+
constraintParts.push(`minLength ${minLength}`);
|
|
1060
|
+
if (maxLength !== undefined)
|
|
1061
|
+
constraintParts.push(`maxLength ${maxLength}`);
|
|
1062
|
+
if (min !== undefined && !Number.isNaN(min))
|
|
1063
|
+
constraintParts.push(`min ${attrs.min}`);
|
|
1064
|
+
if (max !== undefined && !Number.isNaN(max))
|
|
1065
|
+
constraintParts.push(`max ${attrs.max}`);
|
|
1066
|
+
if (step !== undefined && !Number.isNaN(step))
|
|
1067
|
+
constraintParts.push(`step ${attrs.step}`);
|
|
1068
|
+
if (attrs.accept)
|
|
1069
|
+
constraintParts.push(`accept ${attrs.accept}`);
|
|
1070
|
+
if (attrs.autocomplete)
|
|
1071
|
+
constraintParts.push(`autocomplete ${attrs.autocomplete}`);
|
|
1072
|
+
if (attrs.multiple === 'true')
|
|
1073
|
+
constraintParts.push('multiple');
|
|
1074
|
+
const kind = expectedData ?? role;
|
|
1075
|
+
let valid = '';
|
|
1076
|
+
const invalid = [];
|
|
1077
|
+
const padTo = (base, len) => len && base.length < len ? base + 'x'.repeat(len - base.length) : base;
|
|
1078
|
+
switch (kind) {
|
|
1079
|
+
case 'valid_email':
|
|
1080
|
+
case 'email':
|
|
1081
|
+
valid = 'user@example.com';
|
|
1082
|
+
invalid.push('`name@company`', '`abc`');
|
|
1083
|
+
break;
|
|
1084
|
+
case 'password':
|
|
1085
|
+
valid = `\`${padTo('P@ssw0rd!', minLength)}\``;
|
|
1086
|
+
if (minLength)
|
|
1087
|
+
invalid.push(`\`${'a'.repeat(Math.max(1, minLength - 1))}\` (${minLength - 1} chars < minLength)`);
|
|
1088
|
+
else
|
|
1089
|
+
invalid.push('`123` (too weak/short)');
|
|
1090
|
+
break;
|
|
1091
|
+
case 'phone':
|
|
1092
|
+
case 'tel':
|
|
1093
|
+
valid = '`+1 555 0100`';
|
|
1094
|
+
invalid.push('`12ab`');
|
|
1095
|
+
break;
|
|
1096
|
+
case 'date':
|
|
1097
|
+
valid = attrs.min && /^\d{4}-\d{2}-\d{2}$/.test(attrs.min) ? `\`${attrs.min}\`` : '`2026-01-15`';
|
|
1098
|
+
invalid.push('`2026-02-31` (impossible date)');
|
|
1099
|
+
if (attrs.min)
|
|
1100
|
+
invalid.push(`date before \`${attrs.min}\``);
|
|
1101
|
+
if (attrs.max)
|
|
1102
|
+
invalid.push(`date after \`${attrs.max}\``);
|
|
1103
|
+
break;
|
|
1104
|
+
case 'number':
|
|
1105
|
+
valid = `\`${min !== undefined && !Number.isNaN(min) ? min : (max !== undefined && !Number.isNaN(max) ? max : 42)}\``;
|
|
1106
|
+
if (min !== undefined && !Number.isNaN(min))
|
|
1107
|
+
invalid.push(`\`${min - (step && !Number.isNaN(step) ? step : 1)}\` (< min)`);
|
|
1108
|
+
if (max !== undefined && !Number.isNaN(max))
|
|
1109
|
+
invalid.push(`\`${max + (step && !Number.isNaN(step) ? step : 1)}\` (> max)`);
|
|
1110
|
+
invalid.push('`abc` (non-numeric)');
|
|
1111
|
+
break;
|
|
1112
|
+
case 'url':
|
|
1113
|
+
valid = '`https://example.com`';
|
|
1114
|
+
invalid.push('`not-a-url`');
|
|
1115
|
+
break;
|
|
1116
|
+
case 'file':
|
|
1117
|
+
valid = attrs.accept ? `file matching \`${attrs.accept}\`` : 'any file';
|
|
1118
|
+
if (attrs.accept)
|
|
1119
|
+
invalid.push(`file NOT matching \`${attrs.accept}\``);
|
|
1120
|
+
break;
|
|
1121
|
+
case 'one_time_code':
|
|
1122
|
+
valid = '`123456`';
|
|
1123
|
+
invalid.push('`00` (wrong length)', 'expired code');
|
|
1124
|
+
break;
|
|
1125
|
+
case 'checkbox':
|
|
1126
|
+
valid = 'checked / unchecked';
|
|
1127
|
+
if (required)
|
|
1128
|
+
invalid.push('unchecked (required)');
|
|
1129
|
+
break;
|
|
1130
|
+
case 'select':
|
|
1131
|
+
case 'select-one': {
|
|
1132
|
+
try {
|
|
1133
|
+
const opts = attrs.selectOptions ? JSON.parse(attrs.selectOptions) : [];
|
|
1134
|
+
const first = opts.find((o) => o.v !== '');
|
|
1135
|
+
valid = first ? `\`${first.l}\`` : 'any listed option';
|
|
1136
|
+
invalid.push('value not in options list');
|
|
1137
|
+
}
|
|
1138
|
+
catch {
|
|
1139
|
+
valid = 'any listed option';
|
|
1140
|
+
invalid.push('value not in options list');
|
|
1141
|
+
}
|
|
1142
|
+
break;
|
|
1143
|
+
}
|
|
1144
|
+
case 'search_term':
|
|
1145
|
+
valid = '`dashboard`';
|
|
1146
|
+
break;
|
|
1147
|
+
default:
|
|
1148
|
+
if (['text', 'textarea', 'rich-text'].includes(role)) {
|
|
1149
|
+
valid = `\`${padTo('Sample text', minLength)}\``;
|
|
1150
|
+
if (minLength)
|
|
1151
|
+
invalid.push(`${minLength - 1}-char string (< minLength)`);
|
|
1152
|
+
if (maxLength)
|
|
1153
|
+
invalid.push(`${maxLength + 1}-char string (> maxLength)`);
|
|
1154
|
+
if (attrs.pattern)
|
|
1155
|
+
invalid.push(`string violating \`${attrs.pattern}\``);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
if (required && !invalid.some((i) => i.includes('empty')))
|
|
1159
|
+
invalid.unshift('(empty) — required');
|
|
1160
|
+
// State description from static attributes; "Static" only when nothing else is known.
|
|
1161
|
+
const states = [];
|
|
1162
|
+
if (attrs.disabled === 'true')
|
|
1163
|
+
states.push('starts disabled');
|
|
1164
|
+
if (attrs.readOnly === 'true')
|
|
1165
|
+
states.push('read-only');
|
|
1166
|
+
if (attrs.buttonType === 'submit')
|
|
1167
|
+
states.push('triggers form submit; typically disabled until form valid');
|
|
1168
|
+
if (attrs.isDestructive === 'true')
|
|
1169
|
+
states.push('destructive action — expect confirm step');
|
|
1170
|
+
if (required)
|
|
1171
|
+
states.push('invalid → valid once constraints satisfied');
|
|
1172
|
+
return {
|
|
1173
|
+
constraints: constraintParts.join('; ') || '—',
|
|
1174
|
+
valid: valid || '—',
|
|
1175
|
+
invalid: invalid.slice(0, 3).join('; ') || '—',
|
|
1176
|
+
states: states.join('; ') || 'Static',
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
const INPUT_ROLES = ['email', 'password', 'text', 'tel', 'date', 'number', 'url', 'file',
|
|
1180
|
+
'textarea', 'select', 'select-one', 'checkbox', 'radio', 'search', 'rich-text'];
|
|
1181
|
+
async function generateElementBehaviorsFile(summary, screens, askFn, probeObservations) {
|
|
1182
|
+
// Deterministic constraint tables from HTML5 attributes — the LLM only adds
|
|
1183
|
+
// optional prose, so Valid/Invalid columns are never empty again.
|
|
1184
|
+
const MAX_ROWS_PER_SCREEN = 15;
|
|
1185
|
+
const MAX_TOTAL_ROWS = 150;
|
|
1186
|
+
let totalRows = 0;
|
|
1187
|
+
let constrainedCount = 0;
|
|
1188
|
+
const screenSections = screens.map((s) => {
|
|
1189
|
+
if (totalRows >= MAX_TOTAL_ROWS)
|
|
1190
|
+
return '';
|
|
1191
|
+
const probes = probeObservations?.get(s.id) ?? [];
|
|
1192
|
+
const errorByElement = new Map();
|
|
1193
|
+
for (const p of probes) {
|
|
1194
|
+
if (p.observationType === 'VALIDATION_ERROR' && !errorByElement.has(p.elementMeaning)) {
|
|
1195
|
+
errorByElement.set(p.elementMeaning, p.observedText);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
const eligible = s.elements.filter((e) => INPUT_ROLES.includes(e.role) || e.role === 'button' || e.role === 'submit');
|
|
1199
|
+
const droppedPerScreen = Math.max(0, eligible.length - MAX_ROWS_PER_SCREEN);
|
|
1200
|
+
const rows = eligible
|
|
1201
|
+
.slice(0, MAX_ROWS_PER_SCREEN)
|
|
1202
|
+
.map((e) => {
|
|
1203
|
+
let attrs = {};
|
|
1204
|
+
try {
|
|
1205
|
+
attrs = JSON.parse(e.notes ?? '{}');
|
|
1206
|
+
}
|
|
1207
|
+
catch { /* ignore */ }
|
|
1208
|
+
const d = deriveConstraintExamples(e.role, e.expectedData, attrs);
|
|
1209
|
+
if (d.constraints !== '—')
|
|
1210
|
+
constrainedCount++;
|
|
1211
|
+
// Ground state column with real observed validation errors when available
|
|
1212
|
+
const observedError = errorByElement.get(e.meaning);
|
|
1213
|
+
const states = observedError
|
|
1214
|
+
? `${d.states === 'Static' ? '' : d.states + '; '}shows error: "${observedError.slice(0, 80)}"`
|
|
1215
|
+
: d.states;
|
|
1216
|
+
totalRows++;
|
|
1217
|
+
return `| ${e.meaning} | ${e.expectedData ?? e.role} | ${d.constraints} | ${d.valid} | ${d.invalid} | ${states} |`;
|
|
1218
|
+
});
|
|
1219
|
+
if (rows.length === 0)
|
|
1220
|
+
return '';
|
|
1221
|
+
const truncNote = droppedPerScreen > 0
|
|
1222
|
+
? `\n> Note: ${droppedPerScreen} additional element(s) on this screen not shown (limit ${MAX_ROWS_PER_SCREEN} per screen).`
|
|
1223
|
+
: '';
|
|
1224
|
+
return `## Screen: ${s.name}${s.url ? ` (${s.url})` : ''}
|
|
1225
|
+
|
|
1226
|
+
| Element | Expected Data Type | Constraints | Valid Examples | Invalid Examples | State Transitions |
|
|
1227
|
+
|---------|-------------------|-------------|----------------|------------------|-------------------|
|
|
1228
|
+
${rows.join('\n')}${truncNote}`;
|
|
1229
|
+
}).filter(Boolean);
|
|
1230
|
+
// Observed dynamic behaviors from the interactive probe (modals, reveals, messages)
|
|
1231
|
+
const dynamicLines = [];
|
|
1232
|
+
if (probeObservations) {
|
|
1233
|
+
for (const s of screens) {
|
|
1234
|
+
for (const p of (probeObservations.get(s.id) ?? []).slice(0, 8)) {
|
|
1235
|
+
dynamicLines.push(`- **[${s.name}] ${p.elementMeaning}** — ${p.observationType.toLowerCase().replace(/_/g, ' ')}: "${(p.observedText ?? '').slice(0, 120)}" (trigger: ${p.triggeredBy})`);
|
|
1236
|
+
}
|
|
1237
|
+
if (dynamicLines.length >= 40)
|
|
1238
|
+
break;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
// Optional LLM prose — failure falls back to the deterministic content alone.
|
|
1242
|
+
let aiNotes = '';
|
|
1243
|
+
try {
|
|
1244
|
+
aiNotes = await askFn(`You are a QA engineer. Given element constraint tables, write a SHORT "Testing Guidance" section (Markdown, max 200 words): which elements deserve boundary-value tests first and why. No tables, no repetition of the data.`, `${screenSections.join('\n\n').slice(0, 4000)}\n\nElement summary:\n${summary.slice(0, 800)}`);
|
|
1245
|
+
}
|
|
1246
|
+
catch { /* deterministic content stands alone */ }
|
|
1247
|
+
const probeCount = dynamicLines.length;
|
|
1248
|
+
const content = `# Element Behaviors
|
|
1249
|
+
> Constraint table derived deterministically from HTML5 attributes captured at crawl time${probeCount > 0 ? `, grounded with ${probeCount} interactive-probe observations` : ''}.
|
|
1250
|
+
${probeCount === 0 ? '> Enable **interactive probing** in crawl settings to also capture real validation messages and state transitions.\n' : ''}
|
|
1251
|
+
${screenSections.join('\n\n')}
|
|
1252
|
+
|
|
1253
|
+
## Dynamic Elements
|
|
1254
|
+
${dynamicLines.length > 0 ? dynamicLines.join('\n') : '- No dynamic behaviors observed yet. Run a crawl with interactive probing enabled to populate this section.'}
|
|
1255
|
+
${aiNotes ? `\n## Testing Guidance\n${aiNotes}` : ''}`;
|
|
1256
|
+
const tags = extractTags(content, ['validation', 'email', 'password', 'phone', 'date',
|
|
1257
|
+
'required', 'format', 'pattern', 'min', 'max', 'unique', 'disabled', 'readonly', 'file', 'checkbox']);
|
|
1258
|
+
const totalElements = screens.reduce((n, s) => n + s.elements.length, 0);
|
|
1259
|
+
return {
|
|
1260
|
+
content,
|
|
1261
|
+
summary: `Element behaviors: ${totalRows} elements documented (${constrainedCount} with constraints)${probeCount ? `, ${probeCount} probe observations` : ''} of ${totalElements} total.`,
|
|
1262
|
+
tags,
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
// ─── TIER 5: Quality Intelligence (structured data, no LLM) ──────────────────
|
|
1266
|
+
function deriveScreenLabel(s) {
|
|
1267
|
+
if (s.url) {
|
|
1268
|
+
try {
|
|
1269
|
+
const u = new URL(s.url);
|
|
1270
|
+
const segs = u.pathname.split('/').filter(Boolean);
|
|
1271
|
+
if (segs.length > 0) {
|
|
1272
|
+
const IS_ID = (seg) => /^\d+$/.test(seg) || /^[0-9a-f]{8,}$/i.test(seg) || /^c[a-z0-9]{20,}$/i.test(seg);
|
|
1273
|
+
const readable = segs.filter((seg) => !IS_ID(seg)).map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1).replace(/[-_]/g, ' '));
|
|
1274
|
+
if (readable.length > 0)
|
|
1275
|
+
return readable.join(' › ');
|
|
1276
|
+
}
|
|
1277
|
+
return u.pathname === '/' ? 'Home' : u.pathname;
|
|
1278
|
+
}
|
|
1279
|
+
catch { /* fall through */ }
|
|
1280
|
+
}
|
|
1281
|
+
return s.name;
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Column → TestCase.kind mapping for the live Coverage Matrix, matching the
|
|
1285
|
+
* column set already used by generateCoverageMatrixBaseline above (Functional
|
|
1286
|
+
* /Security/A11y/State/Perf/Journey). Kinds actually written by the current
|
|
1287
|
+
* generators — test-case-agent (POSITIVE/NEGATIVE/EDGE/SECURITY/ACCESSIBILITY),
|
|
1288
|
+
* api-test-agent (API), integration-test-agent (INTEGRATION), flow-agent
|
|
1289
|
+
* (E2E/REGRESSION/SMOKE/SANITY) — are folded into the nearest existing column;
|
|
1290
|
+
* API/INTEGRATION count as Functional since there's no dedicated column for
|
|
1291
|
+
* them yet. STATE_TRANSITION/PERFORMANCE are only ever produced by imported
|
|
1292
|
+
* test cases today, but are kept as their own columns per the baseline targets.
|
|
1293
|
+
*/
|
|
1294
|
+
const COVERAGE_COLUMNS = [
|
|
1295
|
+
{ label: 'Functional', kinds: ['POSITIVE', 'NEGATIVE', 'EDGE', 'API', 'INTEGRATION'] },
|
|
1296
|
+
{ label: 'Security', kinds: ['SECURITY'] },
|
|
1297
|
+
{ label: 'A11y', kinds: ['ACCESSIBILITY'] },
|
|
1298
|
+
{ label: 'State', kinds: ['STATE_TRANSITION'] },
|
|
1299
|
+
{ label: 'Perf', kinds: ['PERFORMANCE'] },
|
|
1300
|
+
{ label: 'Journey', kinds: ['E2E', 'REGRESSION', 'SMOKE', 'SANITY'] },
|
|
1301
|
+
];
|
|
1302
|
+
/**
|
|
1303
|
+
* Render the Coverage Matrix from live screen + TestCase-count data. Reuses
|
|
1304
|
+
* the same URL-dedup and generic-title (deriveScreenLabel) logic as the
|
|
1305
|
+
* crawl-time baseline above so screens are named consistently either way.
|
|
1306
|
+
*/
|
|
1307
|
+
function renderCoverageMatrix(screens, counts) {
|
|
1308
|
+
// Deduplicate by URL — keep the row with the most elements for each URL.
|
|
1309
|
+
const seenUrls = new Map();
|
|
1310
|
+
const screenToDedupKey = new Map();
|
|
1311
|
+
for (const s of screens) {
|
|
1312
|
+
const key = s.url ?? s.name;
|
|
1313
|
+
if (s.id)
|
|
1314
|
+
screenToDedupKey.set(s.id, key);
|
|
1315
|
+
const existing = seenUrls.get(key);
|
|
1316
|
+
if (!existing || s.elementCount > existing.elementCount)
|
|
1317
|
+
seenUrls.set(key, s);
|
|
1318
|
+
}
|
|
1319
|
+
const uniqueScreens = Array.from(seenUrls.values());
|
|
1320
|
+
// Detect if all screen names are the same (generic app title) — use URL-derived labels instead.
|
|
1321
|
+
const nameSet = new Set(uniqueScreens.map((s) => s.name.toLowerCase().trim()));
|
|
1322
|
+
const useUrlLabels = nameSet.size === 1 && uniqueScreens.every((s) => s.url);
|
|
1323
|
+
const screenLabel = (s) => {
|
|
1324
|
+
const label = useUrlLabels ? deriveScreenLabel(s) : s.name;
|
|
1325
|
+
return s.url ? `[${label}](${s.url})` : label;
|
|
1326
|
+
};
|
|
1327
|
+
const byScreen = new Map();
|
|
1328
|
+
const byDedupKey = new Map();
|
|
1329
|
+
for (const c of counts) {
|
|
1330
|
+
const m = byScreen.get(c.screenId) ?? new Map();
|
|
1331
|
+
m.set(c.kind, (m.get(c.kind) ?? 0) + c.count);
|
|
1332
|
+
byScreen.set(c.screenId, m);
|
|
1333
|
+
if (c.screenId) {
|
|
1334
|
+
const dedupKey = screenToDedupKey.get(c.screenId);
|
|
1335
|
+
if (dedupKey) {
|
|
1336
|
+
const dm = byDedupKey.get(dedupKey) ?? new Map();
|
|
1337
|
+
dm.set(c.kind, (dm.get(c.kind) ?? 0) + c.count);
|
|
1338
|
+
byDedupKey.set(dedupKey, dm);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
const rows = uniqueScreens.map((s) => {
|
|
1343
|
+
const dedupKey = s.url ?? s.name;
|
|
1344
|
+
const kindCounts = byDedupKey.get(dedupKey) ?? byScreen.get(s.id) ?? new Map();
|
|
1345
|
+
const cols = COVERAGE_COLUMNS.map(({ kinds }) => kinds.reduce((n, k) => n + (kindCounts.get(k) ?? 0), 0));
|
|
1346
|
+
const total = cols.reduce((a, b) => a + b, 0);
|
|
1347
|
+
const security = cols[1];
|
|
1348
|
+
const a11y = cols[2];
|
|
1349
|
+
const gap = total === 0 ? '❌ No TCs — generate now'
|
|
1350
|
+
: security === 0 ? '⚠️ Security missing'
|
|
1351
|
+
: a11y === 0 ? '⚠️ A11y missing'
|
|
1352
|
+
: '✅ Covered';
|
|
1353
|
+
return { label: screenLabel(s), url: s.url, elementCount: s.elementCount, cols, total, gap };
|
|
1354
|
+
});
|
|
1355
|
+
// Cross-screen tests (journeys, API packs) often have no screenId — show them too.
|
|
1356
|
+
const unscoped = byScreen.get(null);
|
|
1357
|
+
if (unscoped && unscoped.size > 0) {
|
|
1358
|
+
const cols = COVERAGE_COLUMNS.map(({ kinds }) => kinds.reduce((n, k) => n + (unscoped.get(k) ?? 0), 0));
|
|
1359
|
+
const total = cols.reduce((a, b) => a + b, 0);
|
|
1360
|
+
if (total > 0)
|
|
1361
|
+
rows.push({ label: '_Cross-screen / journeys_', url: null, elementCount: 0, cols, total, gap: '—' });
|
|
1362
|
+
}
|
|
1363
|
+
const generatedTotal = rows.reduce((n, r) => n + r.total, 0);
|
|
1364
|
+
const coveredScreens = rows.filter((r) => r.gap === '✅ Covered').length;
|
|
1365
|
+
const content = `# Coverage Matrix
|
|
1366
|
+
> Live document — recomputed from the TestCase table after each test generation run.
|
|
1367
|
+
> Last refreshed: ${new Date().toISOString()}
|
|
1368
|
+
|
|
1369
|
+
> **${uniqueScreens.length} unique screens** (${screens.length - uniqueScreens.length} duplicate URL variants excluded from coverage tracking)
|
|
1370
|
+
|
|
1371
|
+
## Coverage Grid (Screens × Test Types)
|
|
1372
|
+
|
|
1373
|
+
| Screen | URL | Elements | ${COVERAGE_COLUMNS.map((c) => c.label).join(' | ')} | Total | Gap |
|
|
1374
|
+
|--------|-----|:--------:|${COVERAGE_COLUMNS.map(() => ':----:').join('|')}|:-----:|:----|
|
|
1375
|
+
${rows.map((r) => `| ${r.label} | ${r.url ?? '—'} | ${r.elementCount || '—'} | ${r.cols.join(' | ')} | **${r.total}** | ${r.gap} |`).join('\n')}
|
|
1376
|
+
|
|
1377
|
+
**Totals:** ${generatedTotal} test cases across ${uniqueScreens.length} unique screens — ${coveredScreens} fully covered.
|
|
1378
|
+
|
|
1379
|
+
## Coverage Targets (per test type, per screen)
|
|
1380
|
+
- Functional (EP+BVA): minimum 1 TC per equivalence class × field
|
|
1381
|
+
- Security: minimum 1 OWASP vector × form input field
|
|
1382
|
+
- Accessibility: minimum 1 WCAG 2.1 AA criterion × applicable element
|
|
1383
|
+
- State Transition: minimum 1 TC per state × event combination
|
|
1384
|
+
- Performance: 1 TC per screen (LCP assertion against PERFORMANCE_BASELINE)
|
|
1385
|
+
- Journey: 1 TC per core user journey (from BUSINESS_DOMAIN)
|
|
1386
|
+
|
|
1387
|
+
## Gap Detection Rules
|
|
1388
|
+
- ❌ No TCs at all → generate now
|
|
1389
|
+
- ⚠️ Security = 0 AND screen has form inputs → generate OWASP tests
|
|
1390
|
+
- ⚠️ A11y = 0 AND screen has interactive elements → generate WCAG tests
|
|
1391
|
+
- ✅ All types covered → mark screen as fully covered
|
|
1392
|
+
`;
|
|
1393
|
+
return {
|
|
1394
|
+
content,
|
|
1395
|
+
summary: generatedTotal === 0
|
|
1396
|
+
? `Coverage matrix baseline: ${uniqueScreens.length} unique screens, all at 0 TCs. Auto-updates after generation.`
|
|
1397
|
+
: `Coverage matrix: ${generatedTotal} TCs across ${uniqueScreens.length} unique screens, ${coveredScreens} fully covered.`,
|
|
1398
|
+
tags: ['coverage', 'matrix', 'gaps', 'regression'],
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Recompute the COVERAGE_MATRIX skill file from live TestCase data.
|
|
1403
|
+
* Called after every test generation run (and after crawls, as the baseline).
|
|
1404
|
+
* Skips human-approved or manually written files so edits are never clobbered.
|
|
1405
|
+
*/
|
|
1406
|
+
async function refreshCoverageMatrix(tenantId, projectId) {
|
|
1407
|
+
// Check per-project skill file config
|
|
1408
|
+
const sfConfig = await loadSkillFileConfigs(tenantId, projectId).then((m) => m.get('COVERAGE_MATRIX'));
|
|
1409
|
+
if (sfConfig?.enabled === false) {
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
const existing = await app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'COVERAGE_MATRIX').catch(() => null);
|
|
1413
|
+
if (existing && (existing.approvedBy || existing.source === 'MANUAL')) {
|
|
1414
|
+
console.log(`[skill-file-agent] COVERAGE_MATRIX is ${existing.approvedBy ? 'approved' : 'manual'} — skipping auto-refresh.`);
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
const { screens, counts } = await app_brain_1.AppBrain.getCoverageMatrixData(tenantId, projectId);
|
|
1418
|
+
if (screens.length === 0)
|
|
1419
|
+
return;
|
|
1420
|
+
const matrix = renderCoverageMatrix(screens, counts);
|
|
1421
|
+
const matrixScreens = screens.map((s) => ({
|
|
1422
|
+
id: s.id ?? `cross-screen:${s.name}`,
|
|
1423
|
+
name: s.name,
|
|
1424
|
+
url: s.url,
|
|
1425
|
+
elements: Array.from({ length: Math.max(0, s.elementCount) }, (_, i) => ({
|
|
1426
|
+
meaning: `Element ${i + 1}`,
|
|
1427
|
+
role: 'unknown',
|
|
1428
|
+
})),
|
|
1429
|
+
}));
|
|
1430
|
+
const approvalStatus = sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING';
|
|
1431
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'COVERAGE_MATRIX', matrix.content, {
|
|
1432
|
+
summary: matrix.summary,
|
|
1433
|
+
tags: matrix.tags,
|
|
1434
|
+
source: 'AUTO',
|
|
1435
|
+
approvalStatus,
|
|
1436
|
+
...skillQualityMetadata('COVERAGE_MATRIX', matrix.content, matrixScreens, undefined, undefined, 'live-db'),
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* Recompute the TEST_CASES skill file.
|
|
1441
|
+
* Emits testing strategy (persona registry, risk matrix, title conventions, exclusion list)
|
|
1442
|
+
* drawn from BUSINESS_DOMAIN, then appends a live inventory of generated test cases.
|
|
1443
|
+
* Runs even when no cases exist so new projects get actionable guidance immediately.
|
|
1444
|
+
*/
|
|
1445
|
+
async function refreshTestCaseInventory(tenantId, projectId) {
|
|
1446
|
+
// Check per-project skill file config
|
|
1447
|
+
const skillKind = 'TEST_CASES';
|
|
1448
|
+
const sfConfig = await loadSkillFileConfigs(tenantId, projectId).then((m) => m.get(skillKind));
|
|
1449
|
+
if (sfConfig?.enabled === false) {
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const [existing, legacyExisting] = await Promise.all([
|
|
1453
|
+
app_brain_1.AppBrain.getSkillFile(tenantId, projectId, skillKind).catch(() => null),
|
|
1454
|
+
app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'TEST_CASE').catch(() => null),
|
|
1455
|
+
]);
|
|
1456
|
+
if (existing && (existing.approvedBy || existing.source === 'MANUAL'))
|
|
1457
|
+
return;
|
|
1458
|
+
if (!existing && legacyExisting && (legacyExisting.approvedBy || legacyExisting.source === 'MANUAL'))
|
|
1459
|
+
return;
|
|
1460
|
+
const [businessDomain, cases] = await Promise.all([
|
|
1461
|
+
app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'BUSINESS_DOMAIN').catch(() => null),
|
|
1462
|
+
app_brain_1.AppBrain.listTestCases(tenantId, projectId, { limit: 500 }).catch(() => []),
|
|
1463
|
+
]);
|
|
1464
|
+
const domainPersonaCtx = businessDomain?.content?.slice(0, 600) ??
|
|
1465
|
+
'> Fill in user roles and permissions for this application.';
|
|
1466
|
+
const strategySections = [
|
|
1467
|
+
'# Test Case Skill File',
|
|
1468
|
+
'',
|
|
1469
|
+
'> Edit this file to shape how test cases are designed for this project. Approved content locks AI from overwriting.',
|
|
1470
|
+
'',
|
|
1471
|
+
'## Personas',
|
|
1472
|
+
domainPersonaCtx,
|
|
1473
|
+
'',
|
|
1474
|
+
'## Title Conventions',
|
|
1475
|
+
'- Positive: "[Screen] successfully [action] with [valid input]"',
|
|
1476
|
+
'- Negative: "[Screen] rejects [invalid input] with [error message]"',
|
|
1477
|
+
'- Edge: "[Screen] handles [boundary condition] correctly"',
|
|
1478
|
+
'- Security: "[Screen] prevents [OWASP attack vector]"',
|
|
1479
|
+
'- Performance: "[Feature] handles [N] concurrent users within [Xms] threshold"',
|
|
1480
|
+
'',
|
|
1481
|
+
'## Risk Matrix',
|
|
1482
|
+
'| Screen Type | Min Cases | Priority | Key Focus |',
|
|
1483
|
+
'|-------------|-----------|----------|-----------|',
|
|
1484
|
+
'| Auth (login / register / reset) | 8 | CRITICAL | Security + all error states |',
|
|
1485
|
+
'| Payment / checkout | 6 | CRITICAL | Validation + payment failure modes |',
|
|
1486
|
+
'| Forms with data submission | 5 | HIGH | BVA + negative + required field tests |',
|
|
1487
|
+
'| Data lists / grids | 3 | MEDIUM | Pagination + empty state + sorting |',
|
|
1488
|
+
'| Read-only / info pages | 2 | LOW | Accessibility + layout |',
|
|
1489
|
+
'',
|
|
1490
|
+
'## Exclusion List',
|
|
1491
|
+
'> Tests NOT to generate for this project (customize per-project):',
|
|
1492
|
+
'- Third-party embed internals (ads, analytics scripts, chat widgets)',
|
|
1493
|
+
'- Browser-native controls (browser back button, OS file picker internals)',
|
|
1494
|
+
'- Admin backend screens unless the crawler explicitly covered them',
|
|
1495
|
+
'',
|
|
1496
|
+
'## Known Regression Risks',
|
|
1497
|
+
'> Add entries after bugs are found and fixed — future generation will include regression coverage:',
|
|
1498
|
+
'- (none yet — add patterns as bugs are discovered)',
|
|
1499
|
+
];
|
|
1500
|
+
if (cases.length === 0) {
|
|
1501
|
+
const content = strategySections.join('\n');
|
|
1502
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, skillKind, content, {
|
|
1503
|
+
summary: 'Test case authoring guidance (no cases yet)',
|
|
1504
|
+
tags: ['test-cases', 'guidance'],
|
|
1505
|
+
source: 'AUTO',
|
|
1506
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
1507
|
+
});
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
const byKind = {};
|
|
1511
|
+
const byStatus = {};
|
|
1512
|
+
for (const tc of cases) {
|
|
1513
|
+
byKind[tc.kind ?? 'UNKNOWN'] = (byKind[tc.kind ?? 'UNKNOWN'] ?? 0) + 1;
|
|
1514
|
+
byStatus[tc.status ?? 'UNKNOWN'] = (byStatus[tc.status ?? 'UNKNOWN'] ?? 0) + 1;
|
|
1515
|
+
}
|
|
1516
|
+
const kindSummary = Object.entries(byKind).sort(([, a], [, b]) => b - a)
|
|
1517
|
+
.map(([k, n]) => `- ${k}: ${n}`).join('\n');
|
|
1518
|
+
const statusSummary = Object.entries(byStatus).sort(([, a], [, b]) => b - a)
|
|
1519
|
+
.map(([k, n]) => `- ${k}: ${n}`).join('\n');
|
|
1520
|
+
const CASE_LIMIT = parseInt(process.env.SKILL_FILE_CASE_LIMIT ?? '200', 10);
|
|
1521
|
+
const caseList = cases.slice(0, CASE_LIMIT).map((tc) => {
|
|
1522
|
+
const ref = tc.ref ? `[${tc.ref}]` : '';
|
|
1523
|
+
const screen = tc.screen?.name ?? '';
|
|
1524
|
+
return `- ${ref} ${tc.title ?? tc.id}${screen ? ` (${screen})` : ''}`;
|
|
1525
|
+
}).join('\n');
|
|
1526
|
+
const inventorySection = [
|
|
1527
|
+
'',
|
|
1528
|
+
'---',
|
|
1529
|
+
'',
|
|
1530
|
+
'## Test Case Inventory',
|
|
1531
|
+
'',
|
|
1532
|
+
`Total: ${cases.length} test cases`,
|
|
1533
|
+
'',
|
|
1534
|
+
'### By Kind',
|
|
1535
|
+
kindSummary,
|
|
1536
|
+
'',
|
|
1537
|
+
'### By Status',
|
|
1538
|
+
statusSummary,
|
|
1539
|
+
'',
|
|
1540
|
+
'### Test Cases',
|
|
1541
|
+
caseList,
|
|
1542
|
+
cases.length > CASE_LIMIT ? `\n… and ${cases.length - CASE_LIMIT} more` : '',
|
|
1543
|
+
];
|
|
1544
|
+
const content = [...strategySections, ...inventorySection].join('\n');
|
|
1545
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, skillKind, content, {
|
|
1546
|
+
summary: `${cases.length} test cases across ${Object.keys(byKind).length} kinds + authoring guidance`,
|
|
1547
|
+
tags: ['test-cases', 'inventory', 'coverage', 'guidance'],
|
|
1548
|
+
source: 'AUTO',
|
|
1549
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Recompute the TEST_SCRIPT skill file.
|
|
1554
|
+
* Emits authoring standards (framework, auth strategy, selector priority, CI constraints, k6 config)
|
|
1555
|
+
* drawn from TECH_STACK, AUTH_SESSION, and LOCATOR_STRATEGY skill files, then appends an
|
|
1556
|
+
* inventory of existing scripts. Runs even when no scripts exist so new projects get
|
|
1557
|
+
* actionable standards before first generation.
|
|
1558
|
+
*/
|
|
1559
|
+
async function refreshScriptInventory(tenantId, projectId) {
|
|
1560
|
+
// Check per-project skill file config
|
|
1561
|
+
const sfConfig = await loadSkillFileConfigs(tenantId, projectId).then((m) => m.get('TEST_SCRIPT'));
|
|
1562
|
+
if (sfConfig?.enabled === false) {
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
const existing = await app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'TEST_SCRIPT').catch(() => null);
|
|
1566
|
+
if (existing && (existing.approvedBy || existing.source === 'MANUAL'))
|
|
1567
|
+
return;
|
|
1568
|
+
const [techStack, authSession, locatorStrategy] = await Promise.all([
|
|
1569
|
+
app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'TECH_STACK').catch(() => null),
|
|
1570
|
+
app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'AUTH_SESSION').catch(() => null),
|
|
1571
|
+
app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'LOCATOR_STRATEGY').catch(() => null),
|
|
1572
|
+
]);
|
|
1573
|
+
const frameworkCtx = techStack?.content?.slice(0, 400) ??
|
|
1574
|
+
'> Fill in: test framework + language (e.g., Playwright/TypeScript, Cypress/JavaScript, k6).';
|
|
1575
|
+
const authCtx = authSession?.content?.slice(0, 600) ??
|
|
1576
|
+
'> Fill in how scripts authenticate:\n' +
|
|
1577
|
+
'> - storageState: capture session once with playwright auth, load in test setup\n' +
|
|
1578
|
+
'> - API login: POST to auth endpoint, store token in Authorization header\n' +
|
|
1579
|
+
'> - UI login: step through login form in beforeAll';
|
|
1580
|
+
const selectorCtx = locatorStrategy?.content?.slice(0, 500) ??
|
|
1581
|
+
'1. `data-testid` — most stable, never changes with UI refactors\n' +
|
|
1582
|
+
'2. ARIA role + accessible name: `getByRole(\'button\', { name: \'Submit\' })`\n' +
|
|
1583
|
+
'3. `getByLabel(\'Field name\')` for form inputs\n' +
|
|
1584
|
+
'4. AVOID: CSS class selectors, nth-child, XPath with positional predicates';
|
|
1585
|
+
const guidanceSections = [
|
|
1586
|
+
'# Test Script Skill File',
|
|
1587
|
+
'',
|
|
1588
|
+
'> Edit this file to guide script generation for this project. Approved content locks AI from overwriting.',
|
|
1589
|
+
'',
|
|
1590
|
+
'## Framework',
|
|
1591
|
+
frameworkCtx,
|
|
1592
|
+
'',
|
|
1593
|
+
'## Auth Strategy',
|
|
1594
|
+
authCtx,
|
|
1595
|
+
'',
|
|
1596
|
+
'## Selector Priority',
|
|
1597
|
+
selectorCtx,
|
|
1598
|
+
'',
|
|
1599
|
+
'## Authoring Standards',
|
|
1600
|
+
'- Base URL: use `process.env.BASE_URL` — never hardcode URLs in scripts',
|
|
1601
|
+
'- Wait strategy: prefer `waitForURL()` and `waitForSelector()` over `waitForTimeout()`',
|
|
1602
|
+
'- Cleanup: reset state via API call in `afterEach` — not UI teardown steps',
|
|
1603
|
+
'- Timeouts: default 30000ms; increase only for documented slow operations',
|
|
1604
|
+
'- CI: all scripts must pass headless; max 4 parallel workers',
|
|
1605
|
+
'',
|
|
1606
|
+
'## k6 Load Test Standards',
|
|
1607
|
+
'> For cases with techniqueOfRecord = "load-test":',
|
|
1608
|
+
'- VU ramp pattern: 30s ramp to target VU → 2min sustained → 30s ramp-down',
|
|
1609
|
+
'- Default thresholds: p95 < 500ms, error rate < 1%',
|
|
1610
|
+
'- k6 script configuration goes in `preconditions` field — NEVER in test steps',
|
|
1611
|
+
'- Test steps must be human-readable BDD actions describing observable behavior',
|
|
1612
|
+
];
|
|
1613
|
+
const cases = await app_brain_1.AppBrain.listTestCases(tenantId, projectId, { limit: 500 }).catch(() => []);
|
|
1614
|
+
const scriptsPerLang = {};
|
|
1615
|
+
for (const tc of cases) {
|
|
1616
|
+
for (const s of (tc.scripts ?? [])) {
|
|
1617
|
+
const lang = s.language ?? 'unknown';
|
|
1618
|
+
if (!scriptsPerLang[lang])
|
|
1619
|
+
scriptsPerLang[lang] = [];
|
|
1620
|
+
scriptsPerLang[lang].push(tc.ref ? `[${tc.ref}] ${tc.title}` : tc.title ?? tc.id);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
const inventorySection = [];
|
|
1624
|
+
if (Object.keys(scriptsPerLang).length > 0) {
|
|
1625
|
+
const totalScripts = Object.values(scriptsPerLang).reduce((sum, arr) => sum + arr.length, 0);
|
|
1626
|
+
const langSections = Object.entries(scriptsPerLang)
|
|
1627
|
+
.sort(([, a], [, b]) => b.length - a.length)
|
|
1628
|
+
.map(([lang, titles]) => {
|
|
1629
|
+
const list = titles.slice(0, 30).map((t) => ` - ${t}`).join('\n');
|
|
1630
|
+
const more = titles.length > 30 ? `\n … and ${titles.length - 30} more` : '';
|
|
1631
|
+
return `### ${lang} (${titles.length} scripts)\n${list}${more}`;
|
|
1632
|
+
}).join('\n\n');
|
|
1633
|
+
inventorySection.push('', '---', '', '## Script Inventory', '', `Total: ${totalScripts} scripts across ${Object.keys(scriptsPerLang).length} framework(s)`, '', langSections);
|
|
1634
|
+
}
|
|
1635
|
+
const content = [...guidanceSections, ...inventorySection].join('\n');
|
|
1636
|
+
const totalScripts = Object.values(scriptsPerLang).reduce((sum, arr) => sum + arr.length, 0);
|
|
1637
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'TEST_SCRIPT', content, {
|
|
1638
|
+
summary: totalScripts > 0
|
|
1639
|
+
? `${totalScripts} scripts in ${Object.keys(scriptsPerLang).join(', ')} + authoring guidance`
|
|
1640
|
+
: 'Script authoring guidance (no scripts yet)',
|
|
1641
|
+
tags: ['scripts', 'guidance', 'frameworks'],
|
|
1642
|
+
source: 'AUTO',
|
|
1643
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
1647
|
+
function buildScreenSummary(screens) {
|
|
1648
|
+
return screens
|
|
1649
|
+
.map((s) => {
|
|
1650
|
+
const els = s.elements.map((e) => {
|
|
1651
|
+
let line = ` - ${e.role}: ${e.meaning}`;
|
|
1652
|
+
if (e.expectedData)
|
|
1653
|
+
line += ` [${e.expectedData}]`;
|
|
1654
|
+
if (e.notes) {
|
|
1655
|
+
try {
|
|
1656
|
+
const attrs = JSON.parse(e.notes);
|
|
1657
|
+
const relevant = Object.entries(attrs)
|
|
1658
|
+
.filter(([k]) => ['required', 'min', 'max', 'minLength', 'maxLength', 'pattern'].includes(k))
|
|
1659
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
1660
|
+
.join(', ');
|
|
1661
|
+
if (relevant)
|
|
1662
|
+
line += ` {${relevant}}`;
|
|
1663
|
+
}
|
|
1664
|
+
catch { /* ignore */ }
|
|
1665
|
+
}
|
|
1666
|
+
return line;
|
|
1667
|
+
}).join('\n');
|
|
1668
|
+
return `Screen: ${s.name}${s.url ? ` (${s.url})` : ''}\n${els}`;
|
|
1669
|
+
})
|
|
1670
|
+
.join('\n\n');
|
|
1671
|
+
}
|
|
1672
|
+
function identifyComponentPatterns(screens) {
|
|
1673
|
+
const roleScreenCount = {};
|
|
1674
|
+
for (const screen of screens) {
|
|
1675
|
+
const roles = new Set(screen.elements.map((e) => e.role));
|
|
1676
|
+
for (const role of roles) {
|
|
1677
|
+
roleScreenCount[role] = (roleScreenCount[role] ?? 0) + 1;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
return Object.entries(roleScreenCount)
|
|
1681
|
+
.filter(([, count]) => count >= 2)
|
|
1682
|
+
.sort(([, a], [, b]) => b - a)
|
|
1683
|
+
.slice(0, 20)
|
|
1684
|
+
.map(([role, count]) => `${role}: appears on ${count}/${screens.length} screens`)
|
|
1685
|
+
.join('\n');
|
|
1686
|
+
}
|
|
1687
|
+
function groupRoutesBySection(routes) {
|
|
1688
|
+
const groups = new Map();
|
|
1689
|
+
for (const route of routes) {
|
|
1690
|
+
const parts = route.pathname.split('/').filter(Boolean);
|
|
1691
|
+
const section = parts[0] ? parts[0].charAt(0).toUpperCase() + parts[0].slice(1) : 'Root';
|
|
1692
|
+
const arr = groups.get(section) ?? [];
|
|
1693
|
+
arr.push(route);
|
|
1694
|
+
groups.set(section, arr);
|
|
1695
|
+
}
|
|
1696
|
+
return [...groups.entries()];
|
|
1697
|
+
}
|
|
1698
|
+
function elementRoleSummary(elements) {
|
|
1699
|
+
const counts = {};
|
|
1700
|
+
for (const e of elements) {
|
|
1701
|
+
counts[e.role] = (counts[e.role] ?? 0) + 1;
|
|
1702
|
+
}
|
|
1703
|
+
return Object.entries(counts)
|
|
1704
|
+
.sort(([, a], [, b]) => b - a)
|
|
1705
|
+
.map(([role, count]) => `- \`${role}\`: ${count} elements`)
|
|
1706
|
+
.join('\n');
|
|
1707
|
+
}
|
|
1708
|
+
function extractTags(content, candidates) {
|
|
1709
|
+
const lower = content.toLowerCase();
|
|
1710
|
+
return candidates.filter((c) => lower.includes(c));
|
|
1711
|
+
}
|
|
1712
|
+
// ─── Refine (human feedback loop) ────────────────────────────────────────────
|
|
1713
|
+
async function refineSkillFile(opts) {
|
|
1714
|
+
const { getTaskAgentLLMConfig } = await import('./llm-config.js');
|
|
1715
|
+
const llmCfg = await getTaskAgentLLMConfig(opts.tenantId, 'appBrain');
|
|
1716
|
+
const SYSTEM_DEFAULT = [
|
|
1717
|
+
`You are ZeTa-AI's Application Intelligence curator. You maintain ${opts.kind} skill files.`,
|
|
1718
|
+
'The user has provided feedback on the current skill file content. Apply the feedback precisely.',
|
|
1719
|
+
'Preserve all correct existing information. Only change what the feedback specifically requests.',
|
|
1720
|
+
'Return ONLY the updated skill file content — no explanation, no preamble, no markdown fences.',
|
|
1721
|
+
].join(' ');
|
|
1722
|
+
const system = await (0, core_1.getAgentPrompt)('skill-file-curator', undefined, opts.tenantId) ?? SYSTEM_DEFAULT;
|
|
1723
|
+
const user = [
|
|
1724
|
+
`SKILL FILE KIND: ${opts.kind}`,
|
|
1725
|
+
'',
|
|
1726
|
+
'CURRENT CONTENT:',
|
|
1727
|
+
opts.currentContent,
|
|
1728
|
+
'',
|
|
1729
|
+
'USER FEEDBACK:',
|
|
1730
|
+
opts.feedback,
|
|
1731
|
+
'',
|
|
1732
|
+
'Return the updated skill file content reflecting the feedback.',
|
|
1733
|
+
].join('\n');
|
|
1734
|
+
let content;
|
|
1735
|
+
try {
|
|
1736
|
+
const t0 = Date.now();
|
|
1737
|
+
const refineSeed = (0, llm_js_1.deterministicSeed)(`${opts.tenantId}:${opts.projectId}:refine-skill:${opts.kind}:${opts.feedback.slice(0, 200)}`);
|
|
1738
|
+
const { text, usage } = await (0, llm_js_1.askTracked)(system, user, llmCfg, undefined, undefined, { temperature: 0, seed: refineSeed });
|
|
1739
|
+
const latencyMs = Date.now() - t0;
|
|
1740
|
+
content = text.replace(/^```[\w-]*\n?/m, '').replace(/\n?```$/m, '').trim();
|
|
1741
|
+
if (content.length < 30) {
|
|
1742
|
+
console.warn(`[skill-file-agent] refineSkillFile got insufficient content for ${opts.kind} — keeping original`);
|
|
1743
|
+
content = opts.currentContent;
|
|
1744
|
+
}
|
|
1745
|
+
(0, app_brain_1.recordLLMCall)({
|
|
1746
|
+
tenantId: opts.tenantId,
|
|
1747
|
+
projectId: opts.projectId,
|
|
1748
|
+
agentType: 'skill_file',
|
|
1749
|
+
label: `refine_${opts.kind}`,
|
|
1750
|
+
entityType: 'skill_file',
|
|
1751
|
+
usage,
|
|
1752
|
+
latencyMs,
|
|
1753
|
+
}).catch(console.error);
|
|
1754
|
+
}
|
|
1755
|
+
catch {
|
|
1756
|
+
content = opts.currentContent;
|
|
1757
|
+
}
|
|
1758
|
+
return {
|
|
1759
|
+
content,
|
|
1760
|
+
summary: `Refined based on user feedback: ${opts.feedback.slice(0, 80)}`,
|
|
1761
|
+
tags: [],
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
async function loadRepresentativeScreenshots(screens, maxCount) {
|
|
1765
|
+
const screensWithShots = screens
|
|
1766
|
+
.filter((s) => s.screenshots && s.screenshots.length > 0)
|
|
1767
|
+
.sort((a, b) => b.elements.length - a.elements.length)
|
|
1768
|
+
.slice(0, maxCount);
|
|
1769
|
+
const images = [];
|
|
1770
|
+
for (const screen of screensWithShots) {
|
|
1771
|
+
const p = screen.screenshots[0].storagePath;
|
|
1772
|
+
try {
|
|
1773
|
+
const buf = await promises_1.default.readFile(p);
|
|
1774
|
+
const ext = p.toLowerCase().split('.').pop();
|
|
1775
|
+
const mediaType = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
|
|
1776
|
+
: ext === 'webp' ? 'image/webp'
|
|
1777
|
+
: ext === 'gif' ? 'image/gif'
|
|
1778
|
+
: 'image/png';
|
|
1779
|
+
images.push({ base64: buf.toString('base64'), mediaType });
|
|
1780
|
+
}
|
|
1781
|
+
catch { /* screenshot missing — skip */ }
|
|
1782
|
+
}
|
|
1783
|
+
return images;
|
|
1784
|
+
}
|
|
1785
|
+
function buildTestFrameworkContent(opts) {
|
|
1786
|
+
const { repoFullName, framework, language, testGlob, styleProfile, treeWasTruncated } = opts;
|
|
1787
|
+
const sp = styleProfile ?? {};
|
|
1788
|
+
const lines = [
|
|
1789
|
+
`# Test Framework`,
|
|
1790
|
+
``,
|
|
1791
|
+
`## Repository`,
|
|
1792
|
+
`- Repo: \`${repoFullName}\``,
|
|
1793
|
+
`- Framework: ${framework}`,
|
|
1794
|
+
`- Language: ${language}`,
|
|
1795
|
+
`- Test glob: \`${testGlob}\``,
|
|
1796
|
+
``,
|
|
1797
|
+
];
|
|
1798
|
+
if (treeWasTruncated) {
|
|
1799
|
+
lines.push(`## Scan Coverage`);
|
|
1800
|
+
lines.push(`> ⚠️ Repository tree was truncated (>100k files). Test file inventory and pattern detection may be incomplete for large monorepos.`);
|
|
1801
|
+
lines.push(``);
|
|
1802
|
+
}
|
|
1803
|
+
lines.push(`## Folder Structure`, `- Test folder: \`${sp.folderPattern ?? 'tests/'}\``, `- Naming pattern: \`${sp.namingPattern ?? 'unknown'}\``, ``);
|
|
1804
|
+
// Detected structure from StyleProfile (GAP-SF-03)
|
|
1805
|
+
const structureLines = [];
|
|
1806
|
+
if (sp.testStructurePattern)
|
|
1807
|
+
structureLines.push(`- Structure pattern: \`${sp.testStructurePattern}\``);
|
|
1808
|
+
if (sp.testLocation)
|
|
1809
|
+
structureLines.push(`- Test files live in: \`${sp.testLocation}\``);
|
|
1810
|
+
if (sp.pageObjectLocation)
|
|
1811
|
+
structureLines.push(`- Page objects live in: \`${sp.pageObjectLocation}\``);
|
|
1812
|
+
if (sp.componentDir)
|
|
1813
|
+
structureLines.push(`- Components: \`${sp.componentDir}\``);
|
|
1814
|
+
if (sp.locatorsDir)
|
|
1815
|
+
structureLines.push(`- Locators/selectors: \`${sp.locatorsDir}\``);
|
|
1816
|
+
if (sp.testDataDir)
|
|
1817
|
+
structureLines.push(`- Test data: \`${sp.testDataDir}\`${sp.isTestFixtureModule ? ' (TS fixture module)' : ''}`);
|
|
1818
|
+
if (sp.fixtureDir && sp.fixtureDir !== sp.testDataDir)
|
|
1819
|
+
structureLines.push(`- Fixtures: \`${sp.fixtureDir}\``);
|
|
1820
|
+
if (sp.configTestDir)
|
|
1821
|
+
structureLines.push(`- Config test dir: \`${sp.configTestDir}\``);
|
|
1822
|
+
if (sp.configBaseURL)
|
|
1823
|
+
structureLines.push(`- Base URL (from config): \`${sp.configBaseURL}\``);
|
|
1824
|
+
if (sp.basePageClass)
|
|
1825
|
+
structureLines.push(`- Base page class: \`${sp.basePageClass}\``);
|
|
1826
|
+
if (sp.driverClass)
|
|
1827
|
+
structureLines.push(`- Driver class: \`${sp.driverClass}\``);
|
|
1828
|
+
if (sp.javaRoot)
|
|
1829
|
+
structureLines.push(`- Java test root: \`${sp.javaRoot}\``);
|
|
1830
|
+
if (sp.javaBasePackage)
|
|
1831
|
+
structureLines.push(`- Java base package: \`${sp.javaBasePackage}\``);
|
|
1832
|
+
if (sp.testLayers && Array.isArray(sp.testLayers) && sp.testLayers.length > 0) {
|
|
1833
|
+
structureLines.push(`- Layer directories: ${sp.testLayers.map((l) => `\`${l}\``).join(', ')}`);
|
|
1834
|
+
}
|
|
1835
|
+
if (sp.testSubDirs && Array.isArray(sp.testSubDirs) && sp.testSubDirs.length > 0) {
|
|
1836
|
+
structureLines.push(`- Sub-directories: ${sp.testSubDirs.map((l) => `\`${l}\``).join(', ')}`);
|
|
1837
|
+
}
|
|
1838
|
+
const tfUtilsDir = sp.utilsDir ?? '';
|
|
1839
|
+
const tfConfigDir = sp.configDir ?? '';
|
|
1840
|
+
if (tfUtilsDir)
|
|
1841
|
+
structureLines.push(`- Utils/helpers: \`${tfUtilsDir}\``);
|
|
1842
|
+
if (tfConfigDir)
|
|
1843
|
+
structureLines.push(`- Config: \`${tfConfigDir}\``);
|
|
1844
|
+
if (structureLines.length > 0) {
|
|
1845
|
+
lines.push(`## Detected Repository Structure`);
|
|
1846
|
+
structureLines.forEach(f => lines.push(f));
|
|
1847
|
+
lines.push(``);
|
|
1848
|
+
}
|
|
1849
|
+
// Test pattern examples (GAP-SF-02 / new)
|
|
1850
|
+
const patternLines = [];
|
|
1851
|
+
if (sp.describePattern)
|
|
1852
|
+
patternLines.push(`### Describe block\n\`\`\`\n${sp.describePattern}\n\`\`\``);
|
|
1853
|
+
if (sp.beforeEachPattern)
|
|
1854
|
+
patternLines.push(`### Setup hook (beforeEach)\n\`\`\`\n${sp.beforeEachPattern}\n\`\`\``);
|
|
1855
|
+
if (sp.pageObjectPattern)
|
|
1856
|
+
patternLines.push(`### Page object usage\n\`\`\`\n${sp.pageObjectPattern}\n\`\`\``);
|
|
1857
|
+
if (patternLines.length > 0) {
|
|
1858
|
+
lines.push(`## Test Pattern Examples`);
|
|
1859
|
+
lines.push(`> Extracted from existing repo tests — match this style when generating new tests.`);
|
|
1860
|
+
lines.push(``);
|
|
1861
|
+
patternLines.forEach(p => lines.push(p, ``));
|
|
1862
|
+
}
|
|
1863
|
+
lines.push(`## Scaffold Files`);
|
|
1864
|
+
const scaffoldMap = {
|
|
1865
|
+
'playwright-ts': ['package.json', 'playwright.config.ts', 'tsconfig.json', '.gitignore', '.env.example', 'tests/fixtures/index.ts'],
|
|
1866
|
+
'playwright-js': ['package.json', 'playwright.config.js', '.gitignore', '.env.example'],
|
|
1867
|
+
'playwright-py': ['requirements.txt', 'pytest.ini', '.gitignore', '.env.example', 'tests/__init__.py', 'tests/conftest.py'],
|
|
1868
|
+
'playwright-java': ['pom.xml', '.gitignore', '.env.example'],
|
|
1869
|
+
'selenium-py': ['requirements.txt', 'pytest.ini', '.gitignore', 'tests/__init__.py', 'tests/conftest.py'],
|
|
1870
|
+
'selenium-java': ['pom.xml', '.gitignore'],
|
|
1871
|
+
'cypress-ts': ['package.json', 'cypress.config.ts', 'tsconfig.json', '.gitignore'],
|
|
1872
|
+
'cypress-js': ['package.json', 'cypress.config.js', '.gitignore'],
|
|
1873
|
+
};
|
|
1874
|
+
const scaffold = scaffoldMap[framework] ?? scaffoldMap['playwright-ts'];
|
|
1875
|
+
scaffold.forEach((f) => lines.push(`- \`${f}\``));
|
|
1876
|
+
lines.push('', `## Run Command`);
|
|
1877
|
+
const runMap = {
|
|
1878
|
+
'playwright-ts': 'npm install && npx playwright install && npm test',
|
|
1879
|
+
'playwright-js': 'npm install && npx playwright install && npm test',
|
|
1880
|
+
'playwright-py': 'pip install -r requirements.txt && playwright install && pytest',
|
|
1881
|
+
'playwright-java': 'mvn test',
|
|
1882
|
+
'selenium-py': 'pip install -r requirements.txt && pytest',
|
|
1883
|
+
'selenium-java': 'mvn test',
|
|
1884
|
+
'cypress-ts': 'npm install && npx cypress run',
|
|
1885
|
+
'cypress-js': 'npm install && npx cypress run',
|
|
1886
|
+
};
|
|
1887
|
+
lines.push(`\`${runMap[framework] ?? 'npm test'}\``);
|
|
1888
|
+
return lines.join('\n');
|
|
1889
|
+
}
|
|
1890
|
+
function buildCodingConventionsContent(opts) {
|
|
1891
|
+
const { repoFullName, framework, styleProfile } = opts;
|
|
1892
|
+
const sp = styleProfile ?? {};
|
|
1893
|
+
// Validate naming/folder patterns — discard if they're clearly wrong for the framework language
|
|
1894
|
+
// e.g. LLM returning TypeScript patterns (.spec.ts) for a Java repo
|
|
1895
|
+
const isJava = framework.includes('java');
|
|
1896
|
+
const rawNamingPattern = sp.namingPattern ?? '';
|
|
1897
|
+
const rawFolderPattern = sp.folderPattern ?? '';
|
|
1898
|
+
const wrongLangPattern = isJava && (rawNamingPattern.includes('.spec.ts') || rawNamingPattern.includes('.spec.js') || rawNamingPattern.includes('.cy.'));
|
|
1899
|
+
const namingPatternDisplay = wrongLangPattern ? 'unknown' : (rawNamingPattern || 'unknown');
|
|
1900
|
+
const folderPatternDisplay = rawFolderPattern || 'unknown';
|
|
1901
|
+
const lines = [
|
|
1902
|
+
`# Coding Conventions`,
|
|
1903
|
+
``,
|
|
1904
|
+
`## Source`,
|
|
1905
|
+
`Extracted from repo: \`${repoFullName}\` · Framework: \`${framework}\``,
|
|
1906
|
+
``,
|
|
1907
|
+
`## Naming`,
|
|
1908
|
+
`- Pattern: \`${namingPatternDisplay}\``,
|
|
1909
|
+
`- Folder: \`${folderPatternDisplay}\``,
|
|
1910
|
+
``,
|
|
1911
|
+
];
|
|
1912
|
+
// Repo structure fields (LLM-detected during scan)
|
|
1913
|
+
const structureFields = [];
|
|
1914
|
+
if (sp.testStructurePattern)
|
|
1915
|
+
structureFields.push(`- Structure pattern: \`${sp.testStructurePattern}\``);
|
|
1916
|
+
if (sp.pageObjectLocation)
|
|
1917
|
+
structureFields.push(`- Page objects live in: \`${sp.pageObjectLocation}\``);
|
|
1918
|
+
if (sp.componentDir)
|
|
1919
|
+
structureFields.push(`- Components live in: \`${sp.componentDir}\``);
|
|
1920
|
+
if (sp.testLocation)
|
|
1921
|
+
structureFields.push(`- Test files live in: \`${sp.testLocation}\``);
|
|
1922
|
+
if (sp.basePageClass)
|
|
1923
|
+
structureFields.push(`- Base page class: \`${sp.basePageClass}\` (extend this for new page objects)`);
|
|
1924
|
+
if (sp.driverClass)
|
|
1925
|
+
structureFields.push(`- Driver/browser class: \`${sp.driverClass}\` (use for driver lifecycle)`);
|
|
1926
|
+
if (sp.testLayers && Array.isArray(sp.testLayers) && sp.testLayers.length > 0) {
|
|
1927
|
+
structureFields.push(`- Layer directories: ${sp.testLayers.map((l) => `\`${l}\``).join(', ')}`);
|
|
1928
|
+
}
|
|
1929
|
+
if (sp.testSubDirs && Array.isArray(sp.testSubDirs) && sp.testSubDirs.length > 0) {
|
|
1930
|
+
structureFields.push(`- Sub-directories: ${sp.testSubDirs.map((l) => `\`${l}\``).join(', ')} (use URL/feature type to pick)`);
|
|
1931
|
+
}
|
|
1932
|
+
if (structureFields.length > 0) {
|
|
1933
|
+
lines.push(`## Repository Structure`);
|
|
1934
|
+
structureFields.forEach(f => lines.push(f));
|
|
1935
|
+
lines.push('');
|
|
1936
|
+
}
|
|
1937
|
+
// Directory layout — heuristic + LLM-detected paths for test data, fixtures, locators.
|
|
1938
|
+
// These are preserved here so script-generation agents always know where to reference data,
|
|
1939
|
+
// even when the heuristic pattern didn't match and LLM inference filled the gap.
|
|
1940
|
+
const dirFields = [];
|
|
1941
|
+
const configTestDir = sp.configTestDir ?? '';
|
|
1942
|
+
const configFixturesFolder = sp.configFixturesFolder ?? '';
|
|
1943
|
+
const configBaseURL = sp.configBaseURL ?? '';
|
|
1944
|
+
const testDataDir = sp.testDataDir ?? '';
|
|
1945
|
+
const fixtureDir = sp.fixtureDir ?? '';
|
|
1946
|
+
const locatorsDir = sp.locatorsDir ?? '';
|
|
1947
|
+
const javaRoot = sp.javaRoot ?? '';
|
|
1948
|
+
const javaBasePackage = sp.javaBasePackage ?? '';
|
|
1949
|
+
if (configTestDir)
|
|
1950
|
+
dirFields.push(`- Test directory (from config): \`${configTestDir}\``);
|
|
1951
|
+
if (configFixturesFolder)
|
|
1952
|
+
dirFields.push(`- Fixtures directory (from config): \`${configFixturesFolder}\``);
|
|
1953
|
+
if (configBaseURL)
|
|
1954
|
+
dirFields.push(`- Base URL: \`${configBaseURL}\``);
|
|
1955
|
+
if (testDataDir)
|
|
1956
|
+
dirFields.push(`- Test data directory: \`${testDataDir}\`${sp.isTestFixtureModule ? ' (TypeScript fixture module — use test.extend(), not JSON import)' : ''}`);
|
|
1957
|
+
if (fixtureDir && fixtureDir !== testDataDir)
|
|
1958
|
+
dirFields.push(`- Fixture directory: \`${fixtureDir}\`${sp.isTestFixtureModule ? ' (TypeScript fixture module)' : ''}`);
|
|
1959
|
+
if (locatorsDir)
|
|
1960
|
+
dirFields.push(`- Locators/selectors directory: \`${locatorsDir}\``);
|
|
1961
|
+
if (javaRoot)
|
|
1962
|
+
dirFields.push(`- Java test source root: \`${javaRoot}\``);
|
|
1963
|
+
if (javaBasePackage)
|
|
1964
|
+
dirFields.push(`- Java base package: \`${javaBasePackage}\``);
|
|
1965
|
+
const utilsDir = sp.utilsDir ?? '';
|
|
1966
|
+
const configDir = sp.configDir ?? '';
|
|
1967
|
+
if (utilsDir)
|
|
1968
|
+
dirFields.push(`- Utils/helpers directory: \`${utilsDir}\``);
|
|
1969
|
+
if (configDir)
|
|
1970
|
+
dirFields.push(`- Config directory: \`${configDir}\``);
|
|
1971
|
+
if (dirFields.length > 0) {
|
|
1972
|
+
lines.push(`## Directory Layout`);
|
|
1973
|
+
dirFields.forEach(f => lines.push(f));
|
|
1974
|
+
lines.push('');
|
|
1975
|
+
}
|
|
1976
|
+
// File naming stems — tell script-gen agents how to name new files
|
|
1977
|
+
const pageFileStem = sp.pageFileStem ?? '';
|
|
1978
|
+
const locatorFileStem = sp.locatorFileStem ?? '';
|
|
1979
|
+
const testDataFileStem = sp.testDataFileStem ?? '';
|
|
1980
|
+
const fixtureFileStem = sp.fixtureFileStem ?? '';
|
|
1981
|
+
const stemFields = [];
|
|
1982
|
+
if (pageFileStem)
|
|
1983
|
+
stemFields.push(`- Page object files: \`${pageFileStem}\``);
|
|
1984
|
+
if (locatorFileStem)
|
|
1985
|
+
stemFields.push(`- Locator files: \`${locatorFileStem}\``);
|
|
1986
|
+
if (testDataFileStem)
|
|
1987
|
+
stemFields.push(`- Test data files: \`${testDataFileStem}\``);
|
|
1988
|
+
if (fixtureFileStem)
|
|
1989
|
+
stemFields.push(`- Fixture files: \`${fixtureFileStem}\``);
|
|
1990
|
+
if (stemFields.length > 0) {
|
|
1991
|
+
lines.push(`## File Naming Patterns`);
|
|
1992
|
+
stemFields.forEach(f => lines.push(f));
|
|
1993
|
+
lines.push('');
|
|
1994
|
+
}
|
|
1995
|
+
if (sp.importLines) {
|
|
1996
|
+
lines.push(`## Standard Imports`, '```');
|
|
1997
|
+
const importArr = Array.isArray(sp.importLines)
|
|
1998
|
+
? sp.importLines
|
|
1999
|
+
: String(sp.importLines).split('\n');
|
|
2000
|
+
importArr.forEach((l) => l.trim() && lines.push(l));
|
|
2001
|
+
lines.push('```', '');
|
|
2002
|
+
}
|
|
2003
|
+
// TypeScript path aliases — critical for correct import generation (new gaps from workflow audit)
|
|
2004
|
+
const tsconfigPaths = sp.tsconfigPaths;
|
|
2005
|
+
const tsconfigBaseUrl = sp.tsconfigBaseUrl;
|
|
2006
|
+
const jestModuleNameMapper = sp.jestModuleNameMapper;
|
|
2007
|
+
if ((tsconfigPaths && Object.keys(tsconfigPaths).length > 0) || tsconfigBaseUrl) {
|
|
2008
|
+
lines.push(`## TypeScript Path Aliases`);
|
|
2009
|
+
lines.push(`> Use these aliases for all imports — do NOT use relative \`../../\` paths when an alias matches.`);
|
|
2010
|
+
if (tsconfigBaseUrl)
|
|
2011
|
+
lines.push(`- baseUrl: \`${tsconfigBaseUrl}\` (imports rooted here when no alias matches)`);
|
|
2012
|
+
if (tsconfigPaths) {
|
|
2013
|
+
Object.entries(tsconfigPaths).forEach(([alias, targets]) => {
|
|
2014
|
+
lines.push(`- \`${alias}\` → \`${targets[0] ?? ''}\``);
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
lines.push('');
|
|
2018
|
+
}
|
|
2019
|
+
if (jestModuleNameMapper && Object.keys(jestModuleNameMapper).length > 0) {
|
|
2020
|
+
lines.push(`## Jest Module Aliases`);
|
|
2021
|
+
Object.entries(jestModuleNameMapper).forEach(([k, v]) => lines.push(`- \`${k}\` → \`${v}\``));
|
|
2022
|
+
lines.push('');
|
|
2023
|
+
}
|
|
2024
|
+
// Pattern samples — describe/beforeEach/pageObject (GAP-SF-02)
|
|
2025
|
+
const patternSamples = [];
|
|
2026
|
+
if (sp.describePattern)
|
|
2027
|
+
patternSamples.push(`### Describe block\n\`\`\`\n${sp.describePattern}\n\`\`\``);
|
|
2028
|
+
if (sp.beforeEachPattern)
|
|
2029
|
+
patternSamples.push(`### Setup hook\n\`\`\`\n${sp.beforeEachPattern}\n\`\`\``);
|
|
2030
|
+
if (sp.pageObjectPattern)
|
|
2031
|
+
patternSamples.push(`### Page object usage\n\`\`\`\n${sp.pageObjectPattern}\n\`\`\``);
|
|
2032
|
+
if (patternSamples.length > 0) {
|
|
2033
|
+
lines.push(`## Pattern Samples`);
|
|
2034
|
+
lines.push(`> Extracted from repo. Match this style in generated tests.`);
|
|
2035
|
+
lines.push(``);
|
|
2036
|
+
patternSamples.forEach(p => lines.push(p, ``));
|
|
2037
|
+
}
|
|
2038
|
+
const conventions = String(sp.conventions ?? '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
2039
|
+
if (conventions.length > 0) {
|
|
2040
|
+
lines.push(`## Conventions`);
|
|
2041
|
+
conventions.forEach((c) => lines.push(`- ${c}`));
|
|
2042
|
+
}
|
|
2043
|
+
else {
|
|
2044
|
+
lines.push(`## Conventions`, `- No explicit conventions detected. Follow ${framework} best practices.`);
|
|
2045
|
+
}
|
|
2046
|
+
return lines.join('\n');
|
|
2047
|
+
}
|
|
2048
|
+
function buildPageObjectsContent(opts) {
|
|
2049
|
+
const { repoFullName, pageObjectInventory } = opts;
|
|
2050
|
+
const lines = [
|
|
2051
|
+
`# Page Object Library`,
|
|
2052
|
+
``,
|
|
2053
|
+
`## Source`,
|
|
2054
|
+
`Extracted from repo: \`${repoFullName}\``,
|
|
2055
|
+
`Total classes: ${pageObjectInventory.length}`,
|
|
2056
|
+
``,
|
|
2057
|
+
];
|
|
2058
|
+
for (const po of pageObjectInventory) {
|
|
2059
|
+
lines.push(`## \`${po.className}\``, `File: \`${po.filePath}\``);
|
|
2060
|
+
if (po.methods && po.methods.length > 0) {
|
|
2061
|
+
lines.push('', 'Methods:');
|
|
2062
|
+
po.methods.slice(0, 30).forEach((m) => {
|
|
2063
|
+
const sig = m.params !== undefined ? `${m.name}(${m.params})` : m.name;
|
|
2064
|
+
const ret = m.returnType ? ` → ${m.returnType}` : '';
|
|
2065
|
+
lines.push(`- \`${sig}${ret}\``);
|
|
2066
|
+
});
|
|
2067
|
+
if (po.methods.length > 30)
|
|
2068
|
+
lines.push(`- … and ${po.methods.length - 30} more`);
|
|
2069
|
+
}
|
|
2070
|
+
lines.push('');
|
|
2071
|
+
}
|
|
2072
|
+
return lines.join('\n');
|
|
2073
|
+
}
|
|
2074
|
+
function buildZetaFrameworkStandardContent(opts) {
|
|
2075
|
+
const { repoFullName, styleProfile } = opts;
|
|
2076
|
+
const sp = styleProfile ?? {};
|
|
2077
|
+
const testStructurePattern = sp.testStructurePattern || '';
|
|
2078
|
+
const pageObjectLocation = sp.pageObjectLocation || '';
|
|
2079
|
+
const testLocation = sp.testLocation || '';
|
|
2080
|
+
const basePageClass = sp.basePageClass || '';
|
|
2081
|
+
const driverClass = sp.driverClass || '';
|
|
2082
|
+
let javaBasePackage = '';
|
|
2083
|
+
if (sp.javaBasePackage) {
|
|
2084
|
+
javaBasePackage = String(sp.javaBasePackage);
|
|
2085
|
+
}
|
|
2086
|
+
else if (sp.folderPattern) {
|
|
2087
|
+
const fp = String(sp.folderPattern);
|
|
2088
|
+
const m = fp.match(/src\/(?:test|main)\/java\/(.+?)(?:\/[^/]*\{[^}]*\}|\/[A-Z][^/]*\.java|\/?$)/);
|
|
2089
|
+
if (m?.[1])
|
|
2090
|
+
javaBasePackage = m[1].replace(/\/$/, '').replace(/\//g, '.');
|
|
2091
|
+
}
|
|
2092
|
+
// Non-zeta-v2 repos: document the ACTUAL repo structure, not Zeta defaults
|
|
2093
|
+
if (testStructurePattern && testStructurePattern !== 'zeta-v2') {
|
|
2094
|
+
const lines = [
|
|
2095
|
+
`# Repo Test Structure — ${repoFullName}`,
|
|
2096
|
+
``,
|
|
2097
|
+
`## Detected Structure Pattern: \`${testStructurePattern}\``,
|
|
2098
|
+
``,
|
|
2099
|
+
`This repo does NOT use Zeta v2 6-layer structure. Script generation will align to repo conventions.`,
|
|
2100
|
+
``,
|
|
2101
|
+
`## Actual Repo Locations`,
|
|
2102
|
+
];
|
|
2103
|
+
if (pageObjectLocation)
|
|
2104
|
+
lines.push(`- Page objects: \`${pageObjectLocation}\``);
|
|
2105
|
+
if (testLocation)
|
|
2106
|
+
lines.push(`- Test files: \`${testLocation}\``);
|
|
2107
|
+
if (basePageClass)
|
|
2108
|
+
lines.push(`- Base page class: \`${basePageClass}\``);
|
|
2109
|
+
if (driverClass)
|
|
2110
|
+
lines.push(`- Driver/browser class: \`${driverClass}\``);
|
|
2111
|
+
if (javaBasePackage)
|
|
2112
|
+
lines.push(`- Java base package: \`${javaBasePackage}\``);
|
|
2113
|
+
lines.push(``, `## Generation Behavior`, testStructurePattern === 'two-tier'
|
|
2114
|
+
? `Two-tier pattern: generates {Feature}Page.java in \`${pageObjectLocation || 'pages dir'}\` + {Feature}Tests.java in \`${testLocation || 'tests dir'}\`.`
|
|
2115
|
+
: `Flat pattern: generates single {Feature}Tests.java in \`${testLocation || 'tests dir'}\`.`, ``, `## Do NOT use Zeta v2 layers`, `No locators/ pages/ actions/ assertions/ testdata/ smoke/ subfolders. Add files to existing repo structure only.`);
|
|
2116
|
+
return lines.join('\n');
|
|
2117
|
+
}
|
|
2118
|
+
// Zeta v2 repos (or unknown — default to Zeta structure)
|
|
2119
|
+
if (!javaBasePackage) {
|
|
2120
|
+
return `# Zeta Framework Standard — ${repoFullName}\n\nNo Java package root detected. Default root \`io.zeta.applications.{appSlug}\` will be used.\n`;
|
|
2121
|
+
}
|
|
2122
|
+
const pkgPath = javaBasePackage.replace(/\./g, '/');
|
|
2123
|
+
const javaRoot = sp.javaRoot || 'src/test/java';
|
|
2124
|
+
const baseClass = basePageClass || 'io.zeta.core.BasePage';
|
|
2125
|
+
const driverInit = driverClass || 'io.zeta.core.DriverFactory';
|
|
2126
|
+
return [
|
|
2127
|
+
`# Zeta Framework Standard — ${repoFullName}`,
|
|
2128
|
+
``,
|
|
2129
|
+
`## Repo Package Root`,
|
|
2130
|
+
`\`${javaBasePackage}\` (from: \`${sp.folderPattern ?? ''}\`)`,
|
|
2131
|
+
``,
|
|
2132
|
+
`## Generated File Structure`,
|
|
2133
|
+
``,
|
|
2134
|
+
`\`\`\``,
|
|
2135
|
+
`${javaRoot}/`,
|
|
2136
|
+
` ${pkgPath}/{feature}/`,
|
|
2137
|
+
` locators/ {Feature}Locators.java → ${javaBasePackage}.{feature}.locators`,
|
|
2138
|
+
` pages/ {Feature}Page.java → ${javaBasePackage}.{feature}.pages`,
|
|
2139
|
+
` actions/ {Feature}Actions.java → ${javaBasePackage}.{feature}.actions`,
|
|
2140
|
+
` assertions/ {Feature}Assertions.java → ${javaBasePackage}.{feature}.assertions`,
|
|
2141
|
+
` testdata/ {Feature}TestData.java → ${javaBasePackage}.{feature}.testdata`,
|
|
2142
|
+
` ${pkgPath}/smoke/`,
|
|
2143
|
+
` {Feature}SmokeTest.java → ${javaBasePackage}.smoke`,
|
|
2144
|
+
`\`\`\``,
|
|
2145
|
+
``,
|
|
2146
|
+
`## Example (feature = login)`,
|
|
2147
|
+
``,
|
|
2148
|
+
`| File | Package |`,
|
|
2149
|
+
`|------|---------|`,
|
|
2150
|
+
`| \`locators/LoginLocators.java\` | \`${javaBasePackage}.login.locators\` |`,
|
|
2151
|
+
`| \`pages/LoginPage.java\` | \`${javaBasePackage}.login.pages\` |`,
|
|
2152
|
+
`| \`actions/LoginActions.java\` | \`${javaBasePackage}.login.actions\` |`,
|
|
2153
|
+
`| \`assertions/LoginAssertions.java\` | \`${javaBasePackage}.login.assertions\` |`,
|
|
2154
|
+
`| \`testdata/LoginTestData.java\` | \`${javaBasePackage}.login.testdata\` |`,
|
|
2155
|
+
`| \`LoginSmokeTest.java\` | \`${javaBasePackage}.smoke\` |`,
|
|
2156
|
+
``,
|
|
2157
|
+
`## Core Infrastructure (never regenerate)`,
|
|
2158
|
+
`- \`${baseClass}\` — WebDriver helpers (open, click, type, waitForVisible…)`,
|
|
2159
|
+
`- \`${driverInit}\` — driver lifecycle (initDriver, getDriver, quitDriver)`,
|
|
2160
|
+
].join('\n');
|
|
2161
|
+
}
|
|
2162
|
+
function buildTestFileStructureContent(opts) {
|
|
2163
|
+
const { repoFullName, testPaths, extractedTestCount, extractedTests } = opts;
|
|
2164
|
+
const lines = [
|
|
2165
|
+
`# Test File Structure`,
|
|
2166
|
+
``,
|
|
2167
|
+
`## Source`,
|
|
2168
|
+
`Repo: \`${repoFullName}\``,
|
|
2169
|
+
`Total test files: ${testPaths.length}`,
|
|
2170
|
+
`Extracted test IDs: ${extractedTestCount}`,
|
|
2171
|
+
``,
|
|
2172
|
+
`## Files`,
|
|
2173
|
+
];
|
|
2174
|
+
// Group by directory
|
|
2175
|
+
const byDir = new Map();
|
|
2176
|
+
for (const p of testPaths) {
|
|
2177
|
+
const parts = p.split('/');
|
|
2178
|
+
const dir = parts.length > 1 ? parts.slice(0, -1).join('/') : '.';
|
|
2179
|
+
if (!byDir.has(dir))
|
|
2180
|
+
byDir.set(dir, []);
|
|
2181
|
+
byDir.get(dir).push(parts[parts.length - 1]);
|
|
2182
|
+
}
|
|
2183
|
+
for (const [dir, files] of [...byDir.entries()].sort()) {
|
|
2184
|
+
lines.push(``, `### \`${dir}/\``);
|
|
2185
|
+
files.slice(0, 20).forEach((f) => lines.push(`- \`${f}\``));
|
|
2186
|
+
if (files.length > 20)
|
|
2187
|
+
lines.push(`- … and ${files.length - 20} more`);
|
|
2188
|
+
}
|
|
2189
|
+
// Write extracted test IDs grouped by file (GAP-SF-04)
|
|
2190
|
+
if (extractedTests && extractedTests.length > 0) {
|
|
2191
|
+
lines.push(``, `## Extracted Test IDs`);
|
|
2192
|
+
lines.push(`> Use these exact IDs when mapping test cases to scripts.`);
|
|
2193
|
+
const byFile = new Map();
|
|
2194
|
+
for (const t of extractedTests) {
|
|
2195
|
+
const arr = byFile.get(t.filePath) ?? [];
|
|
2196
|
+
arr.push(t);
|
|
2197
|
+
byFile.set(t.filePath, arr);
|
|
2198
|
+
}
|
|
2199
|
+
let testsPrinted = 0;
|
|
2200
|
+
const MAX_TESTS_PER_FILE = 30;
|
|
2201
|
+
const MAX_TOTAL_TESTS = 500;
|
|
2202
|
+
for (const [filePath, tests] of [...byFile.entries()].sort()) {
|
|
2203
|
+
if (testsPrinted >= MAX_TOTAL_TESTS) {
|
|
2204
|
+
lines.push(``, `> … and more tests not shown (limit ${MAX_TOTAL_TESTS})`);
|
|
2205
|
+
break;
|
|
2206
|
+
}
|
|
2207
|
+
lines.push(``, `### \`${filePath}\``);
|
|
2208
|
+
// Group by describe block
|
|
2209
|
+
const byDescribe = new Map();
|
|
2210
|
+
for (const t of tests) {
|
|
2211
|
+
const key = t.describeBlock ?? '';
|
|
2212
|
+
const arr = byDescribe.get(key) ?? [];
|
|
2213
|
+
arr.push(t);
|
|
2214
|
+
byDescribe.set(key, arr);
|
|
2215
|
+
}
|
|
2216
|
+
for (const [describe, descTests] of byDescribe) {
|
|
2217
|
+
if (describe)
|
|
2218
|
+
lines.push(`**${describe}**`);
|
|
2219
|
+
const shown = descTests.slice(0, MAX_TESTS_PER_FILE);
|
|
2220
|
+
shown.forEach((t) => {
|
|
2221
|
+
const dynamic = t.dynamic ? ' *(parametrized)*' : '';
|
|
2222
|
+
lines.push(`- \`${t.name}\`${dynamic} (line ${t.lineNumber})`);
|
|
2223
|
+
testsPrinted++;
|
|
2224
|
+
});
|
|
2225
|
+
if (descTests.length > MAX_TESTS_PER_FILE) {
|
|
2226
|
+
lines.push(` - … and ${descTests.length - MAX_TESTS_PER_FILE} more`);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
return lines.join('\n');
|
|
2232
|
+
}
|
|
2233
|
+
function buildRepoAlignedGenerationGuide(opts) {
|
|
2234
|
+
const sp = opts.styleProfile ?? {};
|
|
2235
|
+
const pattern = sp.testStructurePattern || 'two-tier';
|
|
2236
|
+
const pageObjectLocation = sp.pageObjectLocation || '';
|
|
2237
|
+
const testLocation = sp.testLocation || '';
|
|
2238
|
+
const basePageClass = sp.basePageClass || '';
|
|
2239
|
+
const driverClass = sp.driverClass || '';
|
|
2240
|
+
const javaRoot = sp.javaRoot || 'src/test/java';
|
|
2241
|
+
let javaBasePackage = '';
|
|
2242
|
+
if (sp.javaBasePackage) {
|
|
2243
|
+
javaBasePackage = String(sp.javaBasePackage);
|
|
2244
|
+
}
|
|
2245
|
+
else if (sp.folderPattern) {
|
|
2246
|
+
const m = String(sp.folderPattern).match(/^([a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+)/i);
|
|
2247
|
+
if (m?.[1])
|
|
2248
|
+
javaBasePackage = m[1].replace(/\/$/, '').replace(/\//g, '.');
|
|
2249
|
+
}
|
|
2250
|
+
const lines = [];
|
|
2251
|
+
lines.push(`# Script Generation Guide — ${opts.repoFullName}`);
|
|
2252
|
+
lines.push('');
|
|
2253
|
+
lines.push(`> This repo uses a **${pattern}** structure, NOT Zeta v2.`);
|
|
2254
|
+
lines.push('> Do NOT generate the Zeta v2 6-layer bundle (locators/pages/actions/assertions/testdata/smoke).');
|
|
2255
|
+
lines.push('> Generate ONLY the files that match the repo\'s actual layout.');
|
|
2256
|
+
lines.push('');
|
|
2257
|
+
lines.push('## Repo Structure');
|
|
2258
|
+
lines.push('');
|
|
2259
|
+
if (pageObjectLocation)
|
|
2260
|
+
lines.push(`- Page objects / components: \`${pageObjectLocation}/\``);
|
|
2261
|
+
if (testLocation)
|
|
2262
|
+
lines.push(`- Test classes: \`${testLocation}/\``);
|
|
2263
|
+
if (!pageObjectLocation && !testLocation) {
|
|
2264
|
+
lines.push(`- Java source root: \`${javaRoot}/\``);
|
|
2265
|
+
if (javaBasePackage)
|
|
2266
|
+
lines.push(`- Base package: \`${javaBasePackage}\``);
|
|
2267
|
+
}
|
|
2268
|
+
lines.push('');
|
|
2269
|
+
if (basePageClass || driverClass) {
|
|
2270
|
+
lines.push('## Repo Base Classes');
|
|
2271
|
+
lines.push('');
|
|
2272
|
+
if (basePageClass)
|
|
2273
|
+
lines.push(`- Base page class: \`${basePageClass}\` — extend this in page objects`);
|
|
2274
|
+
if (driverClass)
|
|
2275
|
+
lines.push(`- Driver/browser class: \`${driverClass}\` — use this for browser setup, NOT WebDriver directly`);
|
|
2276
|
+
lines.push('');
|
|
2277
|
+
}
|
|
2278
|
+
lines.push('## File Generation Rules');
|
|
2279
|
+
lines.push('');
|
|
2280
|
+
if (pattern === 'flat') {
|
|
2281
|
+
lines.push('Structure is **flat**: generate ONE test class per feature.');
|
|
2282
|
+
lines.push('- Put everything (locators, actions, assertions, test steps) in a single class.');
|
|
2283
|
+
lines.push('- File lives in: ' + (testLocation ? `\`${testLocation}/\`` : `\`${javaRoot}/${javaBasePackage.replace(/\./g, '/')}/\``));
|
|
2284
|
+
}
|
|
2285
|
+
else {
|
|
2286
|
+
lines.push('Structure is **two-tier**: generate TWO files per feature.');
|
|
2287
|
+
lines.push('- **Page Object** — locators + actions + assertions');
|
|
2288
|
+
lines.push(' - Extends: ' + (basePageClass ? `\`${basePageClass}\`` : 'repo\'s base page class'));
|
|
2289
|
+
lines.push(' - Location: ' + (pageObjectLocation ? `\`${pageObjectLocation}/\`` : `\`${javaRoot}/${javaBasePackage.replace(/\./g, '/')}/pages/\``));
|
|
2290
|
+
lines.push('- **Test Class** — test methods only, delegates to page object');
|
|
2291
|
+
lines.push(' - Location: ' + (testLocation ? `\`${testLocation}/\`` : `\`${javaRoot}/${javaBasePackage.replace(/\./g, '/')}/tests/\``));
|
|
2292
|
+
}
|
|
2293
|
+
lines.push('');
|
|
2294
|
+
lines.push('## Critical Rules');
|
|
2295
|
+
lines.push('');
|
|
2296
|
+
lines.push('- NEVER create locators/, actions/, assertions/, testdata/, or smoke/ directories unless they already exist in this repo.');
|
|
2297
|
+
lines.push('- ALWAYS extend the repo\'s existing base classes — do not invent new ones.');
|
|
2298
|
+
lines.push('- ALWAYS use the same import style as the rest of this repo.');
|
|
2299
|
+
lines.push('- ALWAYS match existing test class naming conventions in this repo.');
|
|
2300
|
+
lines.push('- Do NOT add test framework annotations not already used in this repo.');
|
|
2301
|
+
return lines.join('\n');
|
|
2302
|
+
}
|
|
2303
|
+
async function generateRepoSkillFiles(tenantId, projectId, opts) {
|
|
2304
|
+
const saves = [
|
|
2305
|
+
app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'REPO_TEST_FRAMEWORK', buildTestFrameworkContent(opts), {
|
|
2306
|
+
summary: `Framework: ${opts.framework} · ${opts.testPaths.length} test files in ${opts.repoFullName}`,
|
|
2307
|
+
tags: [opts.framework, opts.language, 'repo-scan', 'framework'],
|
|
2308
|
+
source: 'REPO_SCAN',
|
|
2309
|
+
}),
|
|
2310
|
+
app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'REPO_CODING_CONVENTIONS', buildCodingConventionsContent(opts), {
|
|
2311
|
+
summary: `Coding conventions extracted from ${opts.repoFullName}`,
|
|
2312
|
+
tags: [opts.framework, 'conventions', 'repo-scan'],
|
|
2313
|
+
source: 'REPO_SCAN',
|
|
2314
|
+
}),
|
|
2315
|
+
app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'REPO_TEST_FILE_STRUCTURE', buildTestFileStructureContent(opts), {
|
|
2316
|
+
summary: `${opts.testPaths.length} test files · ${opts.extractedTestCount} test IDs in ${opts.repoFullName}`,
|
|
2317
|
+
tags: [opts.framework, 'file-structure', 'repo-scan'],
|
|
2318
|
+
source: 'REPO_SCAN',
|
|
2319
|
+
}),
|
|
2320
|
+
];
|
|
2321
|
+
if (opts.pageObjectInventory.length > 0) {
|
|
2322
|
+
saves.push(app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'REPO_PAGE_OBJECTS', buildPageObjectsContent(opts), {
|
|
2323
|
+
summary: `${opts.pageObjectInventory.length} page object classes in ${opts.repoFullName}`,
|
|
2324
|
+
tags: [opts.framework, 'page-objects', 'pom', 'repo-scan'],
|
|
2325
|
+
source: 'REPO_SCAN',
|
|
2326
|
+
}));
|
|
2327
|
+
}
|
|
2328
|
+
// ZETA_FRAMEWORK_STANDARD: repo-specific file structure summary.
|
|
2329
|
+
if (opts.framework === 'selenium-java') {
|
|
2330
|
+
const standardContent = buildZetaFrameworkStandardContent(opts);
|
|
2331
|
+
const sp = opts.styleProfile ?? {};
|
|
2332
|
+
const testStructurePattern = sp.testStructurePattern || '';
|
|
2333
|
+
saves.push(app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'ZETA_FRAMEWORK_STANDARD', standardContent, {
|
|
2334
|
+
summary: testStructurePattern && testStructurePattern !== 'zeta-v2'
|
|
2335
|
+
? `Repo-aligned structure for ${opts.repoFullName} (${testStructurePattern})`
|
|
2336
|
+
: `Zeta v2 file structure for ${opts.repoFullName} (${opts.framework})`,
|
|
2337
|
+
tags: [opts.framework, 'framework-standard', 'repo-scan'],
|
|
2338
|
+
source: 'REPO_SCAN',
|
|
2339
|
+
}));
|
|
2340
|
+
// When repo uses a non-Zeta structure, overwrite the Zeta generation guide skill file
|
|
2341
|
+
// with a repo-aligned guide. This prevents the LLM from seeing the Zeta 6-layer template
|
|
2342
|
+
// and generating incompatible files.
|
|
2343
|
+
// Also infer two-tier when page objects are found in src/main/java/ even without explicit pattern.
|
|
2344
|
+
const hasMainSourcePages = opts.pageObjectInventory.some((po) => po.filePath?.includes('/main/java/') || po.filePath?.includes('\\main\\java\\'));
|
|
2345
|
+
const effectivePattern = testStructurePattern || (hasMainSourcePages ? 'two-tier' : '');
|
|
2346
|
+
if (effectivePattern && effectivePattern !== 'zeta-v2') {
|
|
2347
|
+
const guideOpts = effectivePattern !== testStructurePattern
|
|
2348
|
+
? { ...opts, styleProfile: { ...(opts.styleProfile ?? {}), testStructurePattern: effectivePattern } }
|
|
2349
|
+
: opts;
|
|
2350
|
+
saves.push(app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'ZETA_FRAMEWORK_SELENIUM_JAVA', buildRepoAlignedGenerationGuide(guideOpts), {
|
|
2351
|
+
summary: `Repo-aligned script generation guide for ${opts.repoFullName} — replaces Zeta v2 defaults`,
|
|
2352
|
+
tags: [opts.framework, 'generation-guide', 'repo-aligned', 'repo-scan'],
|
|
2353
|
+
source: 'REPO_SCAN',
|
|
2354
|
+
}));
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
else if (ZETA_FRAMEWORKS.includes(opts.framework)) {
|
|
2358
|
+
// Non-Java frameworks: populate ZETA_FRAMEWORK_STANDARD from the pre-built generation guide
|
|
2359
|
+
// so script generation agents always have framework context even without a repo connection.
|
|
2360
|
+
const guidePath = getGuidePath(opts.framework);
|
|
2361
|
+
try {
|
|
2362
|
+
const guideContent = await promises_1.default.readFile(guidePath, 'utf8');
|
|
2363
|
+
saves.push(app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'ZETA_FRAMEWORK_STANDARD', guideContent, {
|
|
2364
|
+
summary: `Zeta framework generation guide for ${opts.framework} — ${opts.repoFullName}`,
|
|
2365
|
+
tags: [opts.framework, 'framework-standard', 'zeta-framework', 'repo-scan'],
|
|
2366
|
+
source: 'ZETA_STANDARD',
|
|
2367
|
+
}));
|
|
2368
|
+
}
|
|
2369
|
+
catch {
|
|
2370
|
+
// Guide file not found — skip
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
await Promise.all(saves);
|
|
2374
|
+
}
|
|
2375
|
+
/**
|
|
2376
|
+
* Generates a `REPO_ZETA_OVERLAY` skill file containing repo-specific overrides
|
|
2377
|
+
* derived deterministically from the `styleProfile` produced by repo scan.
|
|
2378
|
+
* This overlay is injected AFTER the base `ZETA_FRAMEWORK_STANDARD` in generation
|
|
2379
|
+
* prompts so repo-specific paths/conventions override generic ZeTa defaults.
|
|
2380
|
+
*/
|
|
2381
|
+
async function alignSkillFile(tenantId, projectId, opts) {
|
|
2382
|
+
const sp = opts.styleProfile ?? {};
|
|
2383
|
+
const sections = [
|
|
2384
|
+
`# Repo-Specific ZeTa Overlay — ${opts.repoFullName}`,
|
|
2385
|
+
`Framework: ${opts.framework} | Language: ${opts.language}`,
|
|
2386
|
+
'',
|
|
2387
|
+
'## File Locations (MUST use these paths — override ZeTa defaults)',
|
|
2388
|
+
];
|
|
2389
|
+
let hasContent = false;
|
|
2390
|
+
const addIfPresent = (label, val) => {
|
|
2391
|
+
if (val && typeof val === 'string' && val.trim()) {
|
|
2392
|
+
sections.push(`- ${label}: ${val.trim()}`);
|
|
2393
|
+
hasContent = true;
|
|
2394
|
+
}
|
|
2395
|
+
};
|
|
2396
|
+
addIfPresent('Spec files dir', sp.testLocation);
|
|
2397
|
+
addIfPresent('Page object dir', sp.pageObjectLocation);
|
|
2398
|
+
addIfPresent('Locators dir', sp.locatorsDir);
|
|
2399
|
+
addIfPresent('Test data dir', sp.testDataDir);
|
|
2400
|
+
addIfPresent('Fixtures dir', sp.fixtureDir);
|
|
2401
|
+
addIfPresent('Base page import (from PO dir)', sp.basePageImport);
|
|
2402
|
+
addIfPresent('Base page class name', sp.basePageClass);
|
|
2403
|
+
addIfPresent('Config-detected spec dir', sp.configTestDir);
|
|
2404
|
+
addIfPresent('Config-detected base URL', sp.configBaseURL);
|
|
2405
|
+
sections.push('', '## Naming Conventions');
|
|
2406
|
+
addIfPresent('File naming pattern', sp.namingPattern);
|
|
2407
|
+
addIfPresent('Folder/class pattern', sp.folderPattern);
|
|
2408
|
+
if (Array.isArray(sp.importLines) && sp.importLines.length > 0) {
|
|
2409
|
+
sections.push('', '## Detected Import Patterns (replicate these exactly)');
|
|
2410
|
+
sp.importLines.slice(0, 10).forEach(line => { sections.push(` ${line}`); hasContent = true; });
|
|
2411
|
+
}
|
|
2412
|
+
if (sp.conventions && typeof sp.conventions === 'string') {
|
|
2413
|
+
sections.push('', '## Code Conventions', sp.conventions.slice(0, 500));
|
|
2414
|
+
hasContent = true;
|
|
2415
|
+
}
|
|
2416
|
+
sections.push('', '## Generation Rules (OVERRIDE base guide when these conflict with ZeTa defaults)', '- Import paths MUST match the File Locations above.', '- base page import path is authoritative — do not use the ZeTa default.', '- Naming MUST follow detected conventions, not generic ZeTa names.', '- File paths in filePaths JSON output MUST use the dirs listed above.');
|
|
2417
|
+
if (!hasContent)
|
|
2418
|
+
return; // no meaningful repo-specific data — skip upsert
|
|
2419
|
+
const content = sections.join('\n');
|
|
2420
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'REPO_ZETA_OVERLAY', content, {
|
|
2421
|
+
summary: `Repo-aligned ZeTa overrides for ${opts.repoFullName} (${opts.framework})`,
|
|
2422
|
+
tags: [opts.framework, opts.language, 'repo-overlay', 'zeta-overlay', 'repo-scan'],
|
|
2423
|
+
source: 'REPO_SCAN',
|
|
2424
|
+
});
|
|
2425
|
+
}
|
|
2426
|
+
// ── Zeta Framework Standard Skill Files ─────────────────────────────────────
|
|
2427
|
+
const ZETA_FRAMEWORKS = [
|
|
2428
|
+
'selenium-java',
|
|
2429
|
+
'playwright-ts',
|
|
2430
|
+
'playwright-js',
|
|
2431
|
+
'playwright-java',
|
|
2432
|
+
'playwright-py',
|
|
2433
|
+
'cypress-ts',
|
|
2434
|
+
'cypress-js',
|
|
2435
|
+
'selenium-py',
|
|
2436
|
+
'wdio-ts',
|
|
2437
|
+
'gherkin',
|
|
2438
|
+
];
|
|
2439
|
+
function getGuideKind(framework) {
|
|
2440
|
+
return `ZETA_FRAMEWORK_${framework.toUpperCase().replace(/-/g, '_')}`;
|
|
2441
|
+
}
|
|
2442
|
+
function getGuidePath(framework) {
|
|
2443
|
+
return node_path_1.default.join(__dirname, '../zeta-framework-v2', framework, 'GENERATION_GUIDE.md');
|
|
2444
|
+
}
|
|
2445
|
+
/**
|
|
2446
|
+
* Upserts one ZETA_FRAMEWORK_{FRAMEWORK} skill file per supported framework.
|
|
2447
|
+
* Called once on project creation so every project has the framework standards
|
|
2448
|
+
* available to script-generation agents from day one.
|
|
2449
|
+
*/
|
|
2450
|
+
async function generateZetaFrameworkSkillFiles(tenantId, projectId) {
|
|
2451
|
+
const saves = [];
|
|
2452
|
+
const reads = ZETA_FRAMEWORKS.map(async (framework) => {
|
|
2453
|
+
const guidePath = getGuidePath(framework);
|
|
2454
|
+
let content;
|
|
2455
|
+
try {
|
|
2456
|
+
content = await promises_1.default.readFile(guidePath, 'utf8');
|
|
2457
|
+
}
|
|
2458
|
+
catch {
|
|
2459
|
+
return; // Guide not yet created — skip silently
|
|
2460
|
+
}
|
|
2461
|
+
const kind = getGuideKind(framework);
|
|
2462
|
+
saves.push(app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, kind, content, {
|
|
2463
|
+
summary: `Zeta Framework v2 standard for ${framework} — injected into script generation`,
|
|
2464
|
+
tags: [framework, 'zeta-framework', 'generation-guide', 'standard'],
|
|
2465
|
+
source: 'ZETA_STANDARD',
|
|
2466
|
+
}));
|
|
2467
|
+
});
|
|
2468
|
+
await Promise.all(reads);
|
|
2469
|
+
await Promise.all(saves);
|
|
2470
|
+
}
|
|
2471
|
+
function truncateForSkillFile(value, max = 180) {
|
|
2472
|
+
return String(value ?? '')
|
|
2473
|
+
.replace(/\s+/g, ' ')
|
|
2474
|
+
.replace(/\|/g, '\\|')
|
|
2475
|
+
.trim()
|
|
2476
|
+
.slice(0, max);
|
|
2477
|
+
}
|
|
2478
|
+
function extractCachedScenarioRows(artifact) {
|
|
2479
|
+
const payload = artifact.cachedScenarios;
|
|
2480
|
+
const scenarios = Array.isArray(payload?.scenarios)
|
|
2481
|
+
? payload.scenarios
|
|
2482
|
+
: Array.isArray(payload)
|
|
2483
|
+
? payload
|
|
2484
|
+
: [];
|
|
2485
|
+
return scenarios
|
|
2486
|
+
.map((s) => ({
|
|
2487
|
+
title: truncateForSkillFile(s?.title ?? s?.name ?? '', 140),
|
|
2488
|
+
requirementId: s?.requirementId ?? s?.traceTo ?? null,
|
|
2489
|
+
priority: s?.priority ?? null,
|
|
2490
|
+
type: s?.type ?? s?.category ?? null,
|
|
2491
|
+
generated: s?.generated === true,
|
|
2492
|
+
sourceExcerpt: s?.sourceExcerpt ?? null,
|
|
2493
|
+
}))
|
|
2494
|
+
.filter((s) => s.title);
|
|
2495
|
+
}
|
|
2496
|
+
function buildArtifactExcerpt(content, max = 900) {
|
|
2497
|
+
const lines = String(content ?? '')
|
|
2498
|
+
.split(/\r?\n/)
|
|
2499
|
+
.map((line) => line.trim())
|
|
2500
|
+
.filter((line) => line &&
|
|
2501
|
+
!line.startsWith('> Auto-extracted') &&
|
|
2502
|
+
!line.startsWith('> Conversion failed') &&
|
|
2503
|
+
!/^#\s+/.test(line));
|
|
2504
|
+
return lines.slice(0, 8).join(' ').replace(/\s+/g, ' ').slice(0, max);
|
|
2505
|
+
}
|
|
2506
|
+
/**
|
|
2507
|
+
* Refresh BUSINESS_DOMAIN from uploaded/imported artifacts.
|
|
2508
|
+
*
|
|
2509
|
+
* Crawl-generated BUSINESS_DOMAIN is useful, but uploaded BRDs/specs often arrive
|
|
2510
|
+
* before a crawl or test import. This live DB refresh makes those documents visible
|
|
2511
|
+
* as project memory immediately without an LLM call. Approved/manual domain files
|
|
2512
|
+
* remain locked.
|
|
2513
|
+
*/
|
|
2514
|
+
async function refreshArtifactKnowledgeSkillFile(tenantId, projectId) {
|
|
2515
|
+
const sfConfig = await loadSkillFileConfigs(tenantId, projectId).then((m) => m.get('BUSINESS_DOMAIN'));
|
|
2516
|
+
if (sfConfig?.enabled === false)
|
|
2517
|
+
return;
|
|
2518
|
+
const existing = await app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'BUSINESS_DOMAIN').catch(() => null);
|
|
2519
|
+
if (existing && (existing.approvedBy || existing.source === 'MANUAL'))
|
|
2520
|
+
return;
|
|
2521
|
+
const [artifacts, requirements] = await Promise.all([
|
|
2522
|
+
app_brain_1.AppBrain.listArtifacts(tenantId, projectId, { limit: 80 }).catch(() => []),
|
|
2523
|
+
app_brain_1.AppBrain.listRequirements(tenantId, projectId).catch(() => []),
|
|
2524
|
+
]);
|
|
2525
|
+
const usableArtifacts = artifacts.filter((artifact) => {
|
|
2526
|
+
const content = artifact.content ?? '';
|
|
2527
|
+
return content.trim().length > 0 && !content.includes('Conversion failed');
|
|
2528
|
+
});
|
|
2529
|
+
if (usableArtifacts.length === 0 && requirements.length === 0)
|
|
2530
|
+
return;
|
|
2531
|
+
const byKind = {};
|
|
2532
|
+
const byDocType = {};
|
|
2533
|
+
const scenarioRows = usableArtifacts.flatMap((artifact) => extractCachedScenarioRows(artifact).map((scenario) => ({ artifact, scenario })));
|
|
2534
|
+
for (const artifact of usableArtifacts) {
|
|
2535
|
+
const kind = artifact.kind ?? 'DOCUMENT';
|
|
2536
|
+
byKind[kind] = (byKind[kind] ?? 0) + 1;
|
|
2537
|
+
const docType = artifact.docType ?? 'UNCLASSIFIED';
|
|
2538
|
+
byDocType[docType] = (byDocType[docType] ?? 0) + 1;
|
|
2539
|
+
}
|
|
2540
|
+
const sourceRows = usableArtifacts.slice(0, 40).map((artifact) => {
|
|
2541
|
+
const name = artifact.originalName ?? artifact.name;
|
|
2542
|
+
const summary = artifact.summary || buildArtifactExcerpt(artifact.content, 180) || 'No summary available yet';
|
|
2543
|
+
return `| ${truncateForSkillFile(name, 70)} | ${artifact.kind ?? 'DOCUMENT'} | ${artifact.docType ?? '-'} | ${artifact.importStatus ?? '-'} | ${truncateForSkillFile(summary, 180)} |`;
|
|
2544
|
+
});
|
|
2545
|
+
const requirementRows = requirements.slice(0, 80).map((req) => `| ${truncateForSkillFile(req.reqId, 24)} | ${truncateForSkillFile(req.title, 100)} | ${req.priority ?? '-'} | ${req.type ?? '-'} | ${req.status ?? '-'} | ${truncateForSkillFile(req.sourceSection ?? '', 80)} |`);
|
|
2546
|
+
const scenarioList = scenarioRows.slice(0, 80).map(({ artifact, scenario }) => {
|
|
2547
|
+
const req = scenario.requirementId ? ` [${scenario.requirementId}]` : '';
|
|
2548
|
+
const generated = scenario.generated ? ' - imported' : '';
|
|
2549
|
+
const excerpt = scenario.sourceExcerpt ? ` - ${truncateForSkillFile(scenario.sourceExcerpt, 160)}` : '';
|
|
2550
|
+
return `- ${scenario.title}${req}${scenario.priority ? ` (${scenario.priority})` : ''}${generated} - source: ${artifact.originalName ?? artifact.name}${excerpt}`;
|
|
2551
|
+
});
|
|
2552
|
+
const excerptSections = usableArtifacts.slice(0, 8).map((artifact) => {
|
|
2553
|
+
const excerpt = buildArtifactExcerpt(artifact.content, 900);
|
|
2554
|
+
if (!excerpt)
|
|
2555
|
+
return '';
|
|
2556
|
+
return `### ${artifact.originalName ?? artifact.name}\n${excerpt}`;
|
|
2557
|
+
}).filter(Boolean);
|
|
2558
|
+
const lines = [
|
|
2559
|
+
'# Business Domain & Imported Artifact Knowledge',
|
|
2560
|
+
'',
|
|
2561
|
+
'> Auto-generated from uploaded project artifacts, extracted requirements, and cached import scenarios. Review before approving.',
|
|
2562
|
+
'',
|
|
2563
|
+
'## Source Summary',
|
|
2564
|
+
`- Artifacts: ${usableArtifacts.length}`,
|
|
2565
|
+
`- Requirements extracted: ${requirements.length}`,
|
|
2566
|
+
`- Cached testable scenarios: ${scenarioRows.length}`,
|
|
2567
|
+
`- Artifact kinds: ${Object.entries(byKind).map(([k, n]) => `${k}: ${n}`).join(' | ') || 'none'}`,
|
|
2568
|
+
`- Document types: ${Object.entries(byDocType).map(([k, n]) => `${k}: ${n}`).join(' | ') || 'none'}`,
|
|
2569
|
+
'',
|
|
2570
|
+
'## Imported Sources',
|
|
2571
|
+
'',
|
|
2572
|
+
'| File | Kind | Doc Type | Import Status | Summary |',
|
|
2573
|
+
'|---|---|---|---|---|',
|
|
2574
|
+
...(sourceRows.length ? sourceRows : ['| - | - | - | - | - |']),
|
|
2575
|
+
'',
|
|
2576
|
+
'## Extracted Requirements',
|
|
2577
|
+
'',
|
|
2578
|
+
'| ID | Title | Priority | Type | Status | Source Section |',
|
|
2579
|
+
'|---|---|---|---|---|---|',
|
|
2580
|
+
...(requirementRows.length ? requirementRows : ['| - | No structured requirements extracted yet | - | - | - | - |']),
|
|
2581
|
+
'',
|
|
2582
|
+
'## Cached Testable Scenarios',
|
|
2583
|
+
'',
|
|
2584
|
+
...(scenarioList.length ? scenarioList : ['- No scenario extraction cache exists yet. Run Extract on an imported artifact to populate this section.']),
|
|
2585
|
+
'',
|
|
2586
|
+
'## Document Excerpts',
|
|
2587
|
+
'',
|
|
2588
|
+
...(excerptSections.length ? excerptSections : ['No artifact excerpts available.']),
|
|
2589
|
+
];
|
|
2590
|
+
const content = lines.join('\n');
|
|
2591
|
+
const provenance = [
|
|
2592
|
+
...usableArtifacts.slice(0, 80).map((artifact) => ({
|
|
2593
|
+
sourceType: 'ARTIFACT',
|
|
2594
|
+
sourceId: artifact.id,
|
|
2595
|
+
name: artifact.originalName ?? artifact.name,
|
|
2596
|
+
kind: artifact.kind ?? null,
|
|
2597
|
+
docType: artifact.docType ?? null,
|
|
2598
|
+
importStatus: artifact.importStatus ?? null,
|
|
2599
|
+
})),
|
|
2600
|
+
...requirements.slice(0, 100).map((req) => ({
|
|
2601
|
+
sourceType: 'REQUIREMENT',
|
|
2602
|
+
sourceId: req.id ?? req.reqId,
|
|
2603
|
+
reqId: req.reqId,
|
|
2604
|
+
title: req.title,
|
|
2605
|
+
priority: req.priority,
|
|
2606
|
+
})),
|
|
2607
|
+
];
|
|
2608
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'BUSINESS_DOMAIN', content, {
|
|
2609
|
+
summary: `${usableArtifacts.length} imported artifacts, ${requirements.length} requirements, ${scenarioRows.length} cached scenarios`,
|
|
2610
|
+
tags: ['business-domain', 'artifacts', 'requirements', 'imported-context'],
|
|
2611
|
+
source: 'AUTO',
|
|
2612
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
2613
|
+
structuredData: {
|
|
2614
|
+
kind: 'BUSINESS_DOMAIN',
|
|
2615
|
+
generatorVersion: 'skill-file-agent:artifact-knowledge:v1',
|
|
2616
|
+
extractionMode: 'live-db',
|
|
2617
|
+
artifactCount: usableArtifacts.length,
|
|
2618
|
+
requirementCount: requirements.length,
|
|
2619
|
+
cachedScenarioCount: scenarioRows.length,
|
|
2620
|
+
artifactKinds: byKind,
|
|
2621
|
+
documentTypes: byDocType,
|
|
2622
|
+
},
|
|
2623
|
+
provenance,
|
|
2624
|
+
confidence: usableArtifacts.length > 0 || requirements.length > 0 ? 0.9 : 0.6,
|
|
2625
|
+
validationStatus: usableArtifacts.length > 0 || requirements.length > 0 ? 'PASS' : 'WARN',
|
|
2626
|
+
validationSummary: usableArtifacts.length > 0 || requirements.length > 0
|
|
2627
|
+
? 'Generated from persisted artifact, requirement, and scenario-cache records.'
|
|
2628
|
+
: 'No uploaded artifact or requirement evidence found.',
|
|
2629
|
+
generatorVersion: 'skill-file-agent:artifact-knowledge:v1',
|
|
2630
|
+
});
|
|
2631
|
+
}
|
|
2632
|
+
/**
|
|
2633
|
+
* Refresh the TEST_CASES skill file from live TestCase data.
|
|
2634
|
+
* Groups test cases by suite, caps at 200 rows, and summarises approval status.
|
|
2635
|
+
*/
|
|
2636
|
+
async function refreshTestCasesSkillFile(tenantId, projectId) {
|
|
2637
|
+
// Check per-project skill file config
|
|
2638
|
+
const sfConfig = await loadSkillFileConfigs(tenantId, projectId).then((m) => m.get('TEST_CASES'));
|
|
2639
|
+
if (sfConfig?.enabled === false) {
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
const existing = await app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'TEST_CASES').catch(() => null);
|
|
2643
|
+
if (existing && (existing.approvedBy || existing.source === 'MANUAL'))
|
|
2644
|
+
return;
|
|
2645
|
+
const cases = await app_brain_1.AppBrain.listTestCases(tenantId, projectId, { limit: 500 }).catch(() => []);
|
|
2646
|
+
if (cases.length === 0)
|
|
2647
|
+
return;
|
|
2648
|
+
const count = cases.length;
|
|
2649
|
+
const byKind = {};
|
|
2650
|
+
const byStatus = {};
|
|
2651
|
+
const bySuite = {};
|
|
2652
|
+
for (const tc of cases) {
|
|
2653
|
+
const k = tc.kind ?? 'UNKNOWN';
|
|
2654
|
+
byKind[k] = (byKind[k] ?? 0) + 1;
|
|
2655
|
+
const s = tc.status ?? 'UNKNOWN';
|
|
2656
|
+
byStatus[s] = (byStatus[s] ?? 0) + 1;
|
|
2657
|
+
const suiteName = (tc.suite?.name ?? tc.suiteId ?? 'Unsorted');
|
|
2658
|
+
(bySuite[suiteName] ??= []).push(tc);
|
|
2659
|
+
}
|
|
2660
|
+
const pos = byKind['POSITIVE'] ?? 0;
|
|
2661
|
+
const neg = byKind['NEGATIVE'] ?? 0;
|
|
2662
|
+
const edge = byKind['EDGE'] ?? 0;
|
|
2663
|
+
const approvedCount = byStatus['APPROVED'] ?? 0;
|
|
2664
|
+
const suiteCount = Object.keys(bySuite).length;
|
|
2665
|
+
const statusBreakdown = Object.entries(byStatus)
|
|
2666
|
+
.sort(([, a], [, b]) => b - a)
|
|
2667
|
+
.map(([k, n]) => `${k}: ${n}`)
|
|
2668
|
+
.join(', ');
|
|
2669
|
+
const SUITE_ROW_CAP = 50;
|
|
2670
|
+
const TOTAL_ROW_CAP = 300;
|
|
2671
|
+
let totalRendered = 0;
|
|
2672
|
+
const suiteSections = Object.entries(bySuite).map(([suiteName, tcs]) => {
|
|
2673
|
+
if (totalRendered >= TOTAL_ROW_CAP)
|
|
2674
|
+
return '';
|
|
2675
|
+
const toRender = tcs.slice(0, Math.min(SUITE_ROW_CAP, TOTAL_ROW_CAP - totalRendered));
|
|
2676
|
+
totalRendered += toRender.length;
|
|
2677
|
+
const rows = toRender.map((tc) => {
|
|
2678
|
+
const ref = tc.ref ?? tc.id.slice(0, 8);
|
|
2679
|
+
const title = (tc.title ?? '').slice(0, 60);
|
|
2680
|
+
return `| TC-${ref} | ${title} | ${tc.kind ?? '—'} | ${tc.priority ?? '—'} | ${tc.status ?? '—'} |`;
|
|
2681
|
+
}).join('\n');
|
|
2682
|
+
return `### Suite: ${suiteName}\n| ID | Title | Kind | Priority | Status |\n|---|---|---|---|---|\n${rows}`;
|
|
2683
|
+
}).join('\n\n');
|
|
2684
|
+
const lines = [
|
|
2685
|
+
'# Test Cases Inventory',
|
|
2686
|
+
'',
|
|
2687
|
+
`**Project:** ${projectId} | **Total:** ${count} | **Updated:** ${new Date().toISOString()}`,
|
|
2688
|
+
'',
|
|
2689
|
+
'## Summary',
|
|
2690
|
+
`- Total: ${count} | Positive: ${pos} | Negative: ${neg} | Edge: ${edge}`,
|
|
2691
|
+
`- Status breakdown: ${statusBreakdown}`,
|
|
2692
|
+
'',
|
|
2693
|
+
'## Test Cases',
|
|
2694
|
+
'',
|
|
2695
|
+
suiteSections,
|
|
2696
|
+
];
|
|
2697
|
+
if (count > totalRendered)
|
|
2698
|
+
lines.push(`\n... and ${count - totalRendered} more`);
|
|
2699
|
+
const content = lines.join('\n');
|
|
2700
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'TEST_CASES', content, {
|
|
2701
|
+
summary: `${count} test cases across ${suiteCount} suites — ${approvedCount} approved`,
|
|
2702
|
+
tags: ['test-cases', 'inventory', 'coverage'],
|
|
2703
|
+
source: 'AUTO',
|
|
2704
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
2705
|
+
});
|
|
2706
|
+
}
|
|
2707
|
+
/**
|
|
2708
|
+
* Refresh the TEST_DATA skill file.
|
|
2709
|
+
* Emits authoring guidance (personas, field constraints, equivalence classes, forbidden patterns)
|
|
2710
|
+
* drawn from DATA_MODEL and BUSINESS_DOMAIN skill files, then appends an inventory of
|
|
2711
|
+
* existing TestData records below a separator. Runs even when no data exists yet so new
|
|
2712
|
+
* projects start with useful guidance immediately.
|
|
2713
|
+
*/
|
|
2714
|
+
async function refreshTestDataSkillFile(tenantId, projectId) {
|
|
2715
|
+
// Check per-project skill file config
|
|
2716
|
+
const sfConfig = await loadSkillFileConfigs(tenantId, projectId).then((m) => m.get('TEST_DATA'));
|
|
2717
|
+
if (sfConfig?.enabled === false) {
|
|
2718
|
+
return;
|
|
2719
|
+
}
|
|
2720
|
+
const existing = await app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'TEST_DATA').catch(() => null);
|
|
2721
|
+
if (existing && (existing.approvedBy || existing.source === 'MANUAL'))
|
|
2722
|
+
return;
|
|
2723
|
+
const [dataModel, businessDomain] = await Promise.all([
|
|
2724
|
+
app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'DATA_MODEL').catch(() => null),
|
|
2725
|
+
app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'BUSINESS_DOMAIN').catch(() => null),
|
|
2726
|
+
]);
|
|
2727
|
+
const { withTenant: wt } = await import('@taodigital/database');
|
|
2728
|
+
const rows = await wt(tenantId, (tx) => tx.testData.findMany({
|
|
2729
|
+
where: { tenantId, projectId, supersededAt: null },
|
|
2730
|
+
include: { testCase: { select: { title: true, ref: true } } },
|
|
2731
|
+
orderBy: { id: 'desc' },
|
|
2732
|
+
take: 100,
|
|
2733
|
+
})).catch(() => []);
|
|
2734
|
+
const domainCtx = businessDomain?.content?.slice(0, 600) ??
|
|
2735
|
+
'> Fill in: what the app does, user roles, key business rules.';
|
|
2736
|
+
const fieldCtx = dataModel?.content?.slice(0, 800) ??
|
|
2737
|
+
'> Fill in per-field: type, min/max length, required, regex, forbidden values.\n' +
|
|
2738
|
+
'> Example:\n' +
|
|
2739
|
+
'> - email: type=email, required=true, maxLength=254\n' +
|
|
2740
|
+
'> - password: minLength=8, must contain uppercase + digit + symbol';
|
|
2741
|
+
const guidanceSections = [
|
|
2742
|
+
'# Test Data Skill File',
|
|
2743
|
+
'',
|
|
2744
|
+
'> Edit this file to guide what test data the AI generates. Once approved, AI will not overwrite it.',
|
|
2745
|
+
'',
|
|
2746
|
+
'## Application Domain',
|
|
2747
|
+
domainCtx,
|
|
2748
|
+
'',
|
|
2749
|
+
'## Test Personas',
|
|
2750
|
+
'> Review and update for this application — these are starting defaults.',
|
|
2751
|
+
'- **Admin**: Full system access. Replace with actual admin credentials for this application.',
|
|
2752
|
+
'- **Standard User**: Regular authenticated user. Replace with actual credentials for this application.',
|
|
2753
|
+
'- **Guest**: Unauthenticated, public screens only.',
|
|
2754
|
+
'> Add more personas for each plan, role, or subscription tier the app supports.',
|
|
2755
|
+
'',
|
|
2756
|
+
'## Field Constraints',
|
|
2757
|
+
fieldCtx,
|
|
2758
|
+
'',
|
|
2759
|
+
'## Equivalence Classes',
|
|
2760
|
+
'For each key field, generate data from ALL three classes:',
|
|
2761
|
+
'- **VALID**: Satisfies all constraints. Use realistic values matching the domain.',
|
|
2762
|
+
'- **INVALID**: Violates one constraint (wrong type, out-of-range, missing required, bad format).',
|
|
2763
|
+
'- **EDGE**: At exact boundaries (minLength, maxLength, min value, max value, empty on optional).',
|
|
2764
|
+
'',
|
|
2765
|
+
'## Forbidden Data Patterns',
|
|
2766
|
+
'- NEVER use real-looking SSNs, credit card numbers, or real national IDs.',
|
|
2767
|
+
'- NEVER copy values from production screenshots or logs.',
|
|
2768
|
+
'- NEVER hardcode environment-specific secrets (tokens, API keys).',
|
|
2769
|
+
'- Use clearly fake but realistic values: test@test.internal, +1-555-0100, 4111111111111111 (test Visa).',
|
|
2770
|
+
'',
|
|
2771
|
+
'## Dynamic Value Rules',
|
|
2772
|
+
'- **Email uniqueness**: Append timestamp suffix when uniqueness is required. Pattern: `user+<timestamp>@test.internal`',
|
|
2773
|
+
'- **Dates**: Use relative offsets (today+7, today-30) rather than absolute dates.',
|
|
2774
|
+
'- **IDs/refs**: Use test-namespace prefix (e.g., `TEST-` or `QA-`).',
|
|
2775
|
+
];
|
|
2776
|
+
const inventorySection = [];
|
|
2777
|
+
if (rows.length > 0) {
|
|
2778
|
+
const count = rows.length;
|
|
2779
|
+
const byKind = {};
|
|
2780
|
+
for (const r of rows) {
|
|
2781
|
+
const k = r.kind ?? 'UNKNOWN';
|
|
2782
|
+
byKind[k] = (byKind[k] ?? 0) + 1;
|
|
2783
|
+
}
|
|
2784
|
+
const kindSummary = ['VALID', 'INVALID', 'EDGE', 'SYNTHETIC']
|
|
2785
|
+
.map((k) => `${k}: ${byKind[k] ?? 0}`)
|
|
2786
|
+
.join(' | ');
|
|
2787
|
+
const sampleLines = rows.slice(0, 20).map((r) => {
|
|
2788
|
+
const tcTitle = r.testCase?.title ?? r.testCaseId;
|
|
2789
|
+
const payload = r.payload ?? {};
|
|
2790
|
+
const keys = Object.keys(typeof payload === 'object' ? payload : {}).join(', ');
|
|
2791
|
+
const preview = JSON.stringify(payload).slice(0, 150);
|
|
2792
|
+
return `### ${tcTitle}\n- Kind: ${r.kind} | Fields: ${keys || '—'}\n- Values: ${preview}`;
|
|
2793
|
+
}).join('\n\n');
|
|
2794
|
+
inventorySection.push('', '---', '', '## Generated Data Inventory', '', `**Total datasets:** ${count} | ${kindSummary}`, '', sampleLines);
|
|
2795
|
+
}
|
|
2796
|
+
const content = [...guidanceSections, ...inventorySection].join('\n');
|
|
2797
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'TEST_DATA', content, {
|
|
2798
|
+
summary: rows.length > 0 ? `${rows.length} datasets + authoring guidance` : 'Test data authoring guidance (seed)',
|
|
2799
|
+
tags: ['test-data', 'guidance', 'inventory'],
|
|
2800
|
+
source: 'AUTO',
|
|
2801
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
2802
|
+
});
|
|
2803
|
+
}
|
|
2804
|
+
/**
|
|
2805
|
+
* Refresh the TEST_SCRIPT skill file from live TestScript records.
|
|
2806
|
+
* Emits authoring standards (framework, auth, selector priority, CI constraints, k6 config)
|
|
2807
|
+
* drawn from TECH_STACK, AUTH_SESSION, and LOCATOR_STRATEGY skill files, then appends an
|
|
2808
|
+
* inventory of existing scripts with code samples. Runs even when no scripts exist yet.
|
|
2809
|
+
*/
|
|
2810
|
+
async function refreshTestScriptSkillFile(tenantId, projectId) {
|
|
2811
|
+
// Check per-project skill file config
|
|
2812
|
+
const sfConfig = await loadSkillFileConfigs(tenantId, projectId).then((m) => m.get('TEST_SCRIPT'));
|
|
2813
|
+
if (sfConfig?.enabled === false) {
|
|
2814
|
+
return;
|
|
2815
|
+
}
|
|
2816
|
+
const existing = await app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'TEST_SCRIPT').catch(() => null);
|
|
2817
|
+
if (existing && (existing.approvedBy || existing.source === 'MANUAL'))
|
|
2818
|
+
return;
|
|
2819
|
+
const [techStack, authSession, locatorStrategy] = await Promise.all([
|
|
2820
|
+
app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'TECH_STACK').catch(() => null),
|
|
2821
|
+
app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'AUTH_SESSION').catch(() => null),
|
|
2822
|
+
app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'LOCATOR_STRATEGY').catch(() => null),
|
|
2823
|
+
]);
|
|
2824
|
+
const { withTenant: wt } = await import('@taodigital/database');
|
|
2825
|
+
const scripts = await wt(tenantId, (tx) => tx.testScript.findMany({
|
|
2826
|
+
where: { tenantId, projectId },
|
|
2827
|
+
select: { id: true, language: true, code: true, validationStatus: true, testCaseId: true },
|
|
2828
|
+
orderBy: { id: 'desc' },
|
|
2829
|
+
take: 50,
|
|
2830
|
+
})).catch(() => []);
|
|
2831
|
+
const frameworkCtx = techStack?.content?.slice(0, 400) ??
|
|
2832
|
+
'> Fill in: test framework + language (e.g., Playwright/TypeScript, Cypress/JavaScript, k6).';
|
|
2833
|
+
const authCtx = authSession?.content?.slice(0, 600) ??
|
|
2834
|
+
'> Fill in how scripts authenticate:\n' +
|
|
2835
|
+
'> - storageState: capture session once with playwright auth, load in test setup\n' +
|
|
2836
|
+
'> - API login: POST to auth endpoint, store token in Authorization header\n' +
|
|
2837
|
+
'> - UI login: step through login form in beforeAll';
|
|
2838
|
+
const selectorCtx = locatorStrategy?.content?.slice(0, 500) ??
|
|
2839
|
+
'1. `data-testid` — most stable, never changes with UI refactors\n' +
|
|
2840
|
+
'2. ARIA role + accessible name: `getByRole(\'button\', { name: \'Submit\' })`\n' +
|
|
2841
|
+
'3. `getByLabel(\'Field name\')` for form inputs\n' +
|
|
2842
|
+
'4. AVOID: CSS class selectors, nth-child, XPath with positional predicates';
|
|
2843
|
+
const guidanceSections = [
|
|
2844
|
+
'# Test Script Skill File',
|
|
2845
|
+
'',
|
|
2846
|
+
'> Edit this file to guide script generation for this project. Approved content locks AI from overwriting.',
|
|
2847
|
+
'',
|
|
2848
|
+
'## Framework',
|
|
2849
|
+
frameworkCtx,
|
|
2850
|
+
'',
|
|
2851
|
+
'## Auth Strategy',
|
|
2852
|
+
authCtx,
|
|
2853
|
+
'',
|
|
2854
|
+
'## Selector Priority',
|
|
2855
|
+
selectorCtx,
|
|
2856
|
+
'',
|
|
2857
|
+
'## Authoring Standards',
|
|
2858
|
+
'- Base URL: use `process.env.BASE_URL` — never hardcode URLs in scripts',
|
|
2859
|
+
'- Wait strategy: prefer `waitForURL()` and `waitForSelector()` over `waitForTimeout()`',
|
|
2860
|
+
'- Cleanup: reset state via API call in `afterEach` — not UI teardown steps',
|
|
2861
|
+
'- Timeouts: default 30000ms; increase only for documented slow operations',
|
|
2862
|
+
'- CI: all scripts must pass headless; max 4 parallel workers',
|
|
2863
|
+
'',
|
|
2864
|
+
'## k6 Load Test Standards',
|
|
2865
|
+
'> For cases with techniqueOfRecord = "load-test":',
|
|
2866
|
+
'- VU ramp pattern: 30s ramp to target VU → 2min sustained → 30s ramp-down',
|
|
2867
|
+
'- Default thresholds: p95 < 500ms, error rate < 1%',
|
|
2868
|
+
'- k6 script configuration goes in `preconditions` field — NEVER in test steps',
|
|
2869
|
+
'- Test steps must be human-readable BDD actions describing observable behavior',
|
|
2870
|
+
];
|
|
2871
|
+
if (scripts.length === 0) {
|
|
2872
|
+
const content = guidanceSections.join('\n');
|
|
2873
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'TEST_SCRIPT', content, {
|
|
2874
|
+
summary: 'Script authoring guidance (no scripts yet)',
|
|
2875
|
+
tags: ['scripts', 'guidance'],
|
|
2876
|
+
source: 'AUTO',
|
|
2877
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
2878
|
+
});
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
const count = scripts.length;
|
|
2882
|
+
const byLang = {};
|
|
2883
|
+
for (const s of scripts) {
|
|
2884
|
+
const l = s.language ?? 'unknown';
|
|
2885
|
+
byLang[l] = (byLang[l] ?? 0) + 1;
|
|
2886
|
+
}
|
|
2887
|
+
const frameworkBreakdown = Object.entries(byLang)
|
|
2888
|
+
.sort(([, a], [, b]) => b - a)
|
|
2889
|
+
.map(([l, n]) => `${l}: ${n}`)
|
|
2890
|
+
.join(' | ');
|
|
2891
|
+
const tableRows = scripts.map((s) => `| ${s.testCaseId.slice(0, 8)} | ${s.language} | ${s.validationStatus ?? 'PENDING'} |`).join('\n');
|
|
2892
|
+
const codeSamples = scripts.slice(0, 3).map((s) => {
|
|
2893
|
+
const lang = s.language ?? 'text';
|
|
2894
|
+
const preview = (s.code ?? '').slice(0, 1500);
|
|
2895
|
+
return `### Script ${s.testCaseId.slice(0, 8)} (${lang})\n\`\`\`${lang}\n${preview}${(s.code?.length ?? 0) > 1500 ? '\n...' : ''}\n\`\`\``;
|
|
2896
|
+
}).join('\n\n');
|
|
2897
|
+
const inventorySection = [
|
|
2898
|
+
'',
|
|
2899
|
+
'---',
|
|
2900
|
+
'',
|
|
2901
|
+
'## Script Inventory',
|
|
2902
|
+
'',
|
|
2903
|
+
`**Total scripts:** ${count} | ${frameworkBreakdown}`,
|
|
2904
|
+
'',
|
|
2905
|
+
'| Test Case ID | Language | Validation |',
|
|
2906
|
+
'|---|---|---|',
|
|
2907
|
+
tableRows,
|
|
2908
|
+
'',
|
|
2909
|
+
'## Code Samples (first 3)',
|
|
2910
|
+
'',
|
|
2911
|
+
codeSamples,
|
|
2912
|
+
];
|
|
2913
|
+
const content = [...guidanceSections, ...inventorySection].join('\n');
|
|
2914
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'TEST_SCRIPT', content, {
|
|
2915
|
+
summary: `${count} scripts — ${frameworkBreakdown} + authoring guidance`,
|
|
2916
|
+
tags: ['scripts', 'guidance', 'inventory', 'frameworks'],
|
|
2917
|
+
source: 'AUTO',
|
|
2918
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
2919
|
+
});
|
|
2920
|
+
}
|
|
2921
|
+
/**
|
|
2922
|
+
* Refresh the REQUIREMENTS_COVERAGE skill file from live requirements and test case data.
|
|
2923
|
+
* Queries requirements, approved test cases, and traces coverage via LLM matching.
|
|
2924
|
+
* No-ops when the skill file has been manually approved or edited.
|
|
2925
|
+
*/
|
|
2926
|
+
async function refreshRequirementsCoverageSkillFile(tenantId, projectId) {
|
|
2927
|
+
// Check per-project skill file config
|
|
2928
|
+
const sfConfig = await loadSkillFileConfigs(tenantId, projectId).then((m) => m.get('REQUIREMENTS_COVERAGE'));
|
|
2929
|
+
if (sfConfig?.enabled === false) {
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
const existing = await app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'REQUIREMENTS_COVERAGE').catch(() => null);
|
|
2933
|
+
if (existing && (existing.approvedBy || existing.source === 'MANUAL'))
|
|
2934
|
+
return;
|
|
2935
|
+
const { generateCoverageGapReport } = await import('./requirements-agent.js');
|
|
2936
|
+
const report = await generateCoverageGapReport({ tenantId, projectId }).catch((e) => {
|
|
2937
|
+
console.warn('[skill-file-agent] REQUIREMENTS_COVERAGE refresh failed:', e?.message ?? e);
|
|
2938
|
+
return null;
|
|
2939
|
+
});
|
|
2940
|
+
if (!report)
|
|
2941
|
+
return;
|
|
2942
|
+
const byPriority = report?.byPriority ?? {};
|
|
2943
|
+
const priorityRows = Object.entries(byPriority).map(([priority, row]) => (`| ${priority} | ${Number(row?.covered ?? 0)} | ${Number(row?.total ?? 0)} |`));
|
|
2944
|
+
const content = [
|
|
2945
|
+
'# Requirements Coverage Analysis',
|
|
2946
|
+
'',
|
|
2947
|
+
'Latest generated requirements coverage analysis for this project.',
|
|
2948
|
+
'',
|
|
2949
|
+
'## Summary By Priority',
|
|
2950
|
+
'',
|
|
2951
|
+
'| Priority | Covered | Total |',
|
|
2952
|
+
'|---|---:|---:|',
|
|
2953
|
+
...(priorityRows.length ? priorityRows : ['| - | 0 | 0 |']),
|
|
2954
|
+
'',
|
|
2955
|
+
'## Top Coverage Gaps',
|
|
2956
|
+
'',
|
|
2957
|
+
...((report?.topGaps ?? []).length
|
|
2958
|
+
? report.topGaps.map((gap) => `- ${gap}`)
|
|
2959
|
+
: ['- No coverage gaps reported.']),
|
|
2960
|
+
'',
|
|
2961
|
+
'## Untested Requirements',
|
|
2962
|
+
'',
|
|
2963
|
+
...((report?.untested ?? []).length
|
|
2964
|
+
? report.untested.map((reqId) => `- ${reqId}`)
|
|
2965
|
+
: ['- None']),
|
|
2966
|
+
'',
|
|
2967
|
+
'## Not Mapped To Crawled Screens',
|
|
2968
|
+
'',
|
|
2969
|
+
...((report?.unbuilt ?? []).length
|
|
2970
|
+
? report.unbuilt.map((reqId) => `- ${reqId}`)
|
|
2971
|
+
: ['- None']),
|
|
2972
|
+
'',
|
|
2973
|
+
'## Covered Requirements',
|
|
2974
|
+
'',
|
|
2975
|
+
...((report?.covered ?? []).length
|
|
2976
|
+
? report.covered.map((row) => `- ${row.reqId}: ${(row.caseRefs ?? []).join(', ') || 'linked test case'}`)
|
|
2977
|
+
: ['- None']),
|
|
2978
|
+
].join('\n');
|
|
2979
|
+
const coveredCount = (report?.covered ?? []).length;
|
|
2980
|
+
const untestedCount = (report?.untested ?? []).length;
|
|
2981
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'REQUIREMENTS_COVERAGE', content, {
|
|
2982
|
+
summary: `Requirements coverage: ${coveredCount} covered, ${untestedCount} untested`,
|
|
2983
|
+
tags: ['requirements', 'coverage', 'analysis'],
|
|
2984
|
+
source: 'AUTO',
|
|
2985
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
2986
|
+
});
|
|
2987
|
+
}
|
|
2988
|
+
/**
|
|
2989
|
+
* GAP-SF-17: TEST_EXECUTION_HISTORY — top recently failed tests with error type + frequency.
|
|
2990
|
+
* Refreshed after each test run completes.
|
|
2991
|
+
*/
|
|
2992
|
+
async function refreshTestExecutionHistorySkillFile(tenantId, projectId) {
|
|
2993
|
+
// Check per-project skill file config
|
|
2994
|
+
const sfConfig = await loadSkillFileConfigs(tenantId, projectId).then((m) => m.get('TEST_EXECUTION_HISTORY'));
|
|
2995
|
+
if (sfConfig?.enabled === false) {
|
|
2996
|
+
return;
|
|
2997
|
+
}
|
|
2998
|
+
const existing = await app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'TEST_EXECUTION_HISTORY').catch(() => null);
|
|
2999
|
+
if (existing && (existing.approvedBy || existing.source === 'MANUAL'))
|
|
3000
|
+
return;
|
|
3001
|
+
const { withTenant } = await import('@taodigital/database');
|
|
3002
|
+
// Fetch recent test results across all runs for this project
|
|
3003
|
+
const results = await withTenant(tenantId, (tx) => tx.testResult.findMany({
|
|
3004
|
+
where: { tenantId, testCase: { projectId } },
|
|
3005
|
+
select: {
|
|
3006
|
+
id: true,
|
|
3007
|
+
status: true,
|
|
3008
|
+
duration: true,
|
|
3009
|
+
error: true,
|
|
3010
|
+
failureAnalysis: true,
|
|
3011
|
+
createdAt: true,
|
|
3012
|
+
testCase: { select: { id: true, title: true, screenId: true } },
|
|
3013
|
+
},
|
|
3014
|
+
orderBy: { createdAt: 'desc' },
|
|
3015
|
+
take: 200,
|
|
3016
|
+
})).catch(() => []);
|
|
3017
|
+
if (results.length === 0) {
|
|
3018
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'TEST_EXECUTION_HISTORY', '# Test Execution History\n\n> No test results recorded yet.', {
|
|
3019
|
+
summary: 'No test results yet', tags: ['execution', 'history', 'flaky'], source: 'AUTO',
|
|
3020
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
3021
|
+
});
|
|
3022
|
+
return;
|
|
3023
|
+
}
|
|
3024
|
+
// Aggregate by test case
|
|
3025
|
+
const byCaseId = new Map();
|
|
3026
|
+
for (const r of results) {
|
|
3027
|
+
const key = r.testCase?.id ?? 'unknown';
|
|
3028
|
+
const entry = byCaseId.get(key) ?? { title: r.testCase?.title ?? key, failures: 0, passes: 0, errors: [], avgDuration: 0, totalDuration: 0 };
|
|
3029
|
+
if (r.status === 'PASSED') {
|
|
3030
|
+
entry.passes++;
|
|
3031
|
+
}
|
|
3032
|
+
else if (r.status === 'FAILED' || r.status === 'ERROR') {
|
|
3033
|
+
entry.failures++;
|
|
3034
|
+
const errText = (r.error ?? r.failureAnalysis ?? '').slice(0, 100);
|
|
3035
|
+
if (errText && !entry.errors.includes(errText))
|
|
3036
|
+
entry.errors.push(errText);
|
|
3037
|
+
}
|
|
3038
|
+
if (r.duration) {
|
|
3039
|
+
entry.totalDuration += r.duration;
|
|
3040
|
+
}
|
|
3041
|
+
byCaseId.set(key, entry);
|
|
3042
|
+
}
|
|
3043
|
+
for (const e of byCaseId.values()) {
|
|
3044
|
+
e.avgDuration = e.totalDuration / Math.max(1, e.failures + e.passes);
|
|
3045
|
+
}
|
|
3046
|
+
const totalRuns = results.length;
|
|
3047
|
+
const failedRuns = results.filter((r) => r.status === 'FAILED' || r.status === 'ERROR').length;
|
|
3048
|
+
const passRate = totalRuns > 0 ? Math.round((1 - failedRuns / totalRuns) * 100) : 100;
|
|
3049
|
+
// Top 10 most-failing tests
|
|
3050
|
+
const topFailing = [...byCaseId.entries()]
|
|
3051
|
+
.filter(([, v]) => v.failures > 0)
|
|
3052
|
+
.sort(([, a], [, b]) => b.failures - a.failures)
|
|
3053
|
+
.slice(0, 10);
|
|
3054
|
+
// Flaky tests: both passes and failures
|
|
3055
|
+
const flaky = [...byCaseId.entries()]
|
|
3056
|
+
.filter(([, v]) => v.failures > 0 && v.passes > 0)
|
|
3057
|
+
.sort(([, a], [, b]) => b.failures - a.failures)
|
|
3058
|
+
.slice(0, 5);
|
|
3059
|
+
const lines = [
|
|
3060
|
+
`# Test Execution History`,
|
|
3061
|
+
``,
|
|
3062
|
+
`> Auto-generated from last ${totalRuns} test results. Refreshed after each test run.`,
|
|
3063
|
+
``,
|
|
3064
|
+
`## Summary`,
|
|
3065
|
+
`- Total results: ${totalRuns}`,
|
|
3066
|
+
`- Pass rate: ${passRate}%`,
|
|
3067
|
+
`- Failing tests (unique): ${topFailing.length}`,
|
|
3068
|
+
`- Flaky tests: ${flaky.length}`,
|
|
3069
|
+
``,
|
|
3070
|
+
];
|
|
3071
|
+
if (topFailing.length > 0) {
|
|
3072
|
+
lines.push(`## Top Failing Tests`);
|
|
3073
|
+
lines.push(`| Test | Failures | Passes | Common Error |`);
|
|
3074
|
+
lines.push(`|---|---|---|---|`);
|
|
3075
|
+
for (const [, v] of topFailing) {
|
|
3076
|
+
lines.push(`| ${v.title.slice(0, 60)} | ${v.failures} | ${v.passes} | ${(v.errors[0] ?? '—').slice(0, 80)} |`);
|
|
3077
|
+
}
|
|
3078
|
+
lines.push(``);
|
|
3079
|
+
}
|
|
3080
|
+
if (flaky.length > 0) {
|
|
3081
|
+
lines.push(`## Flaky Tests (avoid these patterns in new scripts)`);
|
|
3082
|
+
for (const [, v] of flaky) {
|
|
3083
|
+
lines.push(`- **${v.title}**: ${v.failures} failures / ${v.passes} passes — "${v.errors[0] ?? 'intermittent'}"`);
|
|
3084
|
+
}
|
|
3085
|
+
lines.push(``);
|
|
3086
|
+
}
|
|
3087
|
+
lines.push(`## Script Generation Guidance`);
|
|
3088
|
+
lines.push(`- Avoid tight timing assertions — flaky tests often fail due to race conditions`);
|
|
3089
|
+
lines.push(`- High-failure tests indicate unstable selectors or auth issues — check LOCATOR_STRATEGY`);
|
|
3090
|
+
lines.push(`- Avg duration for failing tests: ${Math.round([...byCaseId.values()].filter((v) => v.failures > 0).reduce((a, v) => a + v.avgDuration, 0) / Math.max(1, topFailing.length))}ms`);
|
|
3091
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'TEST_EXECUTION_HISTORY', lines.join('\n'), {
|
|
3092
|
+
summary: `Execution history: ${passRate}% pass rate, ${topFailing.length} failing tests, ${flaky.length} flaky`,
|
|
3093
|
+
tags: ['execution', 'history', 'flaky', 'failures'],
|
|
3094
|
+
source: 'AUTO',
|
|
3095
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
3096
|
+
});
|
|
3097
|
+
}
|
|
3098
|
+
/**
|
|
3099
|
+
* GAP-SF-18: VISUAL_REGRESSION_HISTORY — screens with recent diffs, change types, stable vs unstable zones.
|
|
3100
|
+
* Refreshed after visual diff generation.
|
|
3101
|
+
*/
|
|
3102
|
+
async function refreshVisualRegressionHistorySkillFile(tenantId, projectId) {
|
|
3103
|
+
// Check per-project skill file config
|
|
3104
|
+
const sfConfig = await loadSkillFileConfigs(tenantId, projectId).then((m) => m.get('VISUAL_REGRESSION_HISTORY'));
|
|
3105
|
+
if (sfConfig?.enabled === false) {
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
const existing = await app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'VISUAL_REGRESSION_HISTORY').catch(() => null);
|
|
3109
|
+
if (existing && (existing.approvedBy || existing.source === 'MANUAL'))
|
|
3110
|
+
return;
|
|
3111
|
+
const { withTenant } = await import('@taodigital/database');
|
|
3112
|
+
const diffs = await withTenant(tenantId, (tx) => tx.visualDiff.findMany({
|
|
3113
|
+
where: {
|
|
3114
|
+
tenantId,
|
|
3115
|
+
baseline: { projectId },
|
|
3116
|
+
},
|
|
3117
|
+
select: {
|
|
3118
|
+
id: true,
|
|
3119
|
+
diffScore: true,
|
|
3120
|
+
changeType: true,
|
|
3121
|
+
recommendation: true,
|
|
3122
|
+
aiSummary: true,
|
|
3123
|
+
status: true,
|
|
3124
|
+
createdAt: true,
|
|
3125
|
+
baseline: { select: { screenId: true } },
|
|
3126
|
+
},
|
|
3127
|
+
orderBy: { createdAt: 'desc' },
|
|
3128
|
+
take: 100,
|
|
3129
|
+
})).catch(() => []);
|
|
3130
|
+
if (diffs.length === 0) {
|
|
3131
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'VISUAL_REGRESSION_HISTORY', '# Visual Regression History\n\n> No visual diffs recorded yet.', {
|
|
3132
|
+
summary: 'No visual diffs yet', tags: ['visual', 'regression', 'baseline'], source: 'AUTO',
|
|
3133
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
3134
|
+
});
|
|
3135
|
+
return;
|
|
3136
|
+
}
|
|
3137
|
+
// Aggregate by screen
|
|
3138
|
+
const byScreen = new Map();
|
|
3139
|
+
for (const d of diffs) {
|
|
3140
|
+
const key = d.baseline?.screenId ?? 'unknown';
|
|
3141
|
+
const entry = byScreen.get(key) ?? { diffs: [], maxScore: 0 };
|
|
3142
|
+
entry.diffs.push(d);
|
|
3143
|
+
if (d.diffScore > entry.maxScore)
|
|
3144
|
+
entry.maxScore = d.diffScore;
|
|
3145
|
+
byScreen.set(key, entry);
|
|
3146
|
+
}
|
|
3147
|
+
const criticalScreens = [...byScreen.entries()]
|
|
3148
|
+
.filter(([, v]) => v.maxScore >= 20)
|
|
3149
|
+
.sort(([, a], [, b]) => b.maxScore - a.maxScore)
|
|
3150
|
+
.slice(0, 10);
|
|
3151
|
+
const stableScreens = [...byScreen.entries()]
|
|
3152
|
+
.filter(([, v]) => v.maxScore < 5)
|
|
3153
|
+
.slice(0, 5);
|
|
3154
|
+
const lines = [
|
|
3155
|
+
`# Visual Regression History`,
|
|
3156
|
+
``,
|
|
3157
|
+
`> Auto-generated from last ${diffs.length} visual diffs. Refreshed after each diff run.`,
|
|
3158
|
+
``,
|
|
3159
|
+
`## Summary`,
|
|
3160
|
+
`- Total diffs analyzed: ${diffs.length}`,
|
|
3161
|
+
`- Screens with regressions: ${criticalScreens.length}`,
|
|
3162
|
+
`- Stable screens (score < 5): ${stableScreens.length}`,
|
|
3163
|
+
``,
|
|
3164
|
+
];
|
|
3165
|
+
if (criticalScreens.length > 0) {
|
|
3166
|
+
lines.push(`## High-Risk Screens (unstable — visual assertions may be flaky)`);
|
|
3167
|
+
lines.push(`| Screen ID | Max Diff Score | Change Type | Recommendation |`);
|
|
3168
|
+
lines.push(`|---|---|---|---|`);
|
|
3169
|
+
for (const [screenId, v] of criticalScreens) {
|
|
3170
|
+
const latest = v.diffs[0];
|
|
3171
|
+
lines.push(`| ${screenId.slice(0, 12)} | ${v.maxScore.toFixed(1)} | ${latest?.changeType ?? '—'} | ${latest?.recommendation ?? '—'} |`);
|
|
3172
|
+
}
|
|
3173
|
+
lines.push(``);
|
|
3174
|
+
}
|
|
3175
|
+
if (stableScreens.length > 0) {
|
|
3176
|
+
lines.push(`## Stable Screens (safe for strict visual assertions)`);
|
|
3177
|
+
for (const [screenId] of stableScreens) {
|
|
3178
|
+
lines.push(`- \`${screenId.slice(0, 12)}\``);
|
|
3179
|
+
}
|
|
3180
|
+
lines.push(``);
|
|
3181
|
+
}
|
|
3182
|
+
lines.push(`## Visual Test Guidance`);
|
|
3183
|
+
lines.push(`- Avoid pixel-perfect assertions on high-risk screens — use element-level assertions instead`);
|
|
3184
|
+
lines.push(`- Safe screens for screenshot comparison: diff score < 5 consistently`);
|
|
3185
|
+
lines.push(`- CRITICAL/SIGNIFICANT changes indicate component-level visual regressions — prioritize for review`);
|
|
3186
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'VISUAL_REGRESSION_HISTORY', lines.join('\n'), {
|
|
3187
|
+
summary: `Visual history: ${criticalScreens.length} unstable screens, ${stableScreens.length} stable`,
|
|
3188
|
+
tags: ['visual', 'regression', 'baseline', 'stability'],
|
|
3189
|
+
source: 'AUTO',
|
|
3190
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
/**
|
|
3194
|
+
* GAP-SF-19: Append self-healing history to LOCATOR_STRATEGY — elements with healCount > 2 are high-fragility.
|
|
3195
|
+
* Refreshed after self-healing events.
|
|
3196
|
+
*/
|
|
3197
|
+
async function refreshLocatorStrategyWithHealingData(tenantId, projectId) {
|
|
3198
|
+
// Check per-project skill file config
|
|
3199
|
+
const sfConfig = await loadSkillFileConfigs(tenantId, projectId).then((m) => m.get('LOCATOR_STRATEGY'));
|
|
3200
|
+
if (sfConfig?.enabled === false) {
|
|
3201
|
+
return;
|
|
3202
|
+
}
|
|
3203
|
+
const existing = await app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'LOCATOR_STRATEGY').catch(() => null);
|
|
3204
|
+
if (!existing)
|
|
3205
|
+
return;
|
|
3206
|
+
if (existing.approvedBy || existing.source === 'MANUAL')
|
|
3207
|
+
return;
|
|
3208
|
+
const { withTenant } = await import('@taodigital/database');
|
|
3209
|
+
const fingerprints = await withTenant(tenantId, (tx) => tx.elementFingerprint.findMany({
|
|
3210
|
+
where: { tenantId, projectId, healCount: { gt: 0 } },
|
|
3211
|
+
select: {
|
|
3212
|
+
elementId: true,
|
|
3213
|
+
healCount: true,
|
|
3214
|
+
lastHealedAt: true,
|
|
3215
|
+
element: { select: { meaning: true, role: true, screen: { select: { name: true } } } },
|
|
3216
|
+
},
|
|
3217
|
+
orderBy: { healCount: 'desc' },
|
|
3218
|
+
take: 50,
|
|
3219
|
+
})).catch(() => []);
|
|
3220
|
+
if (fingerprints.length === 0)
|
|
3221
|
+
return;
|
|
3222
|
+
const highFragility = fingerprints.filter((f) => f.healCount > 2);
|
|
3223
|
+
const lowFragility = fingerprints.filter((f) => f.healCount <= 2);
|
|
3224
|
+
const healSection = [
|
|
3225
|
+
``,
|
|
3226
|
+
`## Self-Healing History`,
|
|
3227
|
+
`> Elements that required selector healing — use stable alternatives for these in new scripts.`,
|
|
3228
|
+
``,
|
|
3229
|
+
];
|
|
3230
|
+
if (highFragility.length > 0) {
|
|
3231
|
+
healSection.push(`### High-Fragility Locators (healed 3+ times — avoid CSS/positional selectors)`);
|
|
3232
|
+
for (const f of highFragility) {
|
|
3233
|
+
const el = f.element;
|
|
3234
|
+
healSection.push(`- **[${el?.screen?.name ?? 'unknown'}] ${el?.meaning ?? f.elementId}** (${el?.role ?? 'unknown'}) — healed ${f.healCount}× — prefer \`data-testid\` or ARIA role+name`);
|
|
3235
|
+
}
|
|
3236
|
+
healSection.push(``);
|
|
3237
|
+
}
|
|
3238
|
+
if (lowFragility.length > 0) {
|
|
3239
|
+
healSection.push(`### Low-Fragility Locators (healed 1-2 times — monitor)`);
|
|
3240
|
+
for (const f of lowFragility.slice(0, 10)) {
|
|
3241
|
+
const el = f.element;
|
|
3242
|
+
healSection.push(`- [${el?.screen?.name ?? 'unknown'}] ${el?.meaning ?? f.elementId} — healed ${f.healCount}×`);
|
|
3243
|
+
}
|
|
3244
|
+
healSection.push(``);
|
|
3245
|
+
}
|
|
3246
|
+
const updatedContent = existing.content + healSection.join('\n');
|
|
3247
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'LOCATOR_STRATEGY', updatedContent, {
|
|
3248
|
+
summary: `${existing.summary ?? 'Locator strategy'} + ${fingerprints.length} self-healing records`,
|
|
3249
|
+
tags: ['locators', 'self-healing', 'fragility', 'stability'],
|
|
3250
|
+
source: 'AUTO',
|
|
3251
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
3252
|
+
});
|
|
3253
|
+
}
|
|
3254
|
+
/**
|
|
3255
|
+
* GAP-SF-20: FIELD_CATALOG — deterministic ground-truth field inventory from AppField table.
|
|
3256
|
+
* Refreshed after crawl when AppField catalog is populated.
|
|
3257
|
+
*/
|
|
3258
|
+
async function refreshFieldCatalogSkillFile(tenantId, projectId) {
|
|
3259
|
+
// Check per-project skill file config
|
|
3260
|
+
const sfConfig = await loadSkillFileConfigs(tenantId, projectId).then((m) => m.get('FIELD_CATALOG'));
|
|
3261
|
+
if (sfConfig?.enabled === false) {
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
const existing = await app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'FIELD_CATALOG').catch(() => null);
|
|
3265
|
+
if (existing && (existing.approvedBy || existing.source === 'MANUAL'))
|
|
3266
|
+
return;
|
|
3267
|
+
const fields = await app_brain_1.AppBrain.listAppFields(tenantId, projectId).catch(() => []);
|
|
3268
|
+
if (fields.length === 0) {
|
|
3269
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'FIELD_CATALOG', '# Field Catalog\n\n> No app fields catalogued yet. Run a crawl to populate.', {
|
|
3270
|
+
summary: 'No fields yet', tags: ['fields', 'catalog', 'data-model'], source: 'AUTO',
|
|
3271
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
3272
|
+
});
|
|
3273
|
+
return;
|
|
3274
|
+
}
|
|
3275
|
+
// Group by screen/entity
|
|
3276
|
+
const byEntity = new Map();
|
|
3277
|
+
for (const f of fields) {
|
|
3278
|
+
const key = f.screenName ?? f.entityName ?? 'Global';
|
|
3279
|
+
const arr = byEntity.get(key) ?? [];
|
|
3280
|
+
arr.push(f);
|
|
3281
|
+
byEntity.set(key, arr);
|
|
3282
|
+
}
|
|
3283
|
+
const lines = [
|
|
3284
|
+
`# Field Catalog`,
|
|
3285
|
+
``,
|
|
3286
|
+
`> Deterministic ground-truth from AppField table — populated during crawl. Authoritative over DATA_MODEL for field types and constraints.`,
|
|
3287
|
+
``,
|
|
3288
|
+
`## Summary`,
|
|
3289
|
+
`- Total fields: ${fields.length}`,
|
|
3290
|
+
`- Entities/screens: ${byEntity.size}`,
|
|
3291
|
+
``,
|
|
3292
|
+
];
|
|
3293
|
+
for (const [entity, entityFields] of [...byEntity.entries()].sort()) {
|
|
3294
|
+
lines.push(`## ${entity}`);
|
|
3295
|
+
lines.push(`| Field | Type | Required | Constraints | Notes |`);
|
|
3296
|
+
lines.push(`|---|---|---|---|---|`);
|
|
3297
|
+
for (const f of entityFields.slice(0, 50)) {
|
|
3298
|
+
const constraints = [
|
|
3299
|
+
f.minLength != null ? `minLen:${f.minLength}` : null,
|
|
3300
|
+
f.maxLength != null ? `maxLen:${f.maxLength}` : null,
|
|
3301
|
+
f.pattern ? `pattern:${f.pattern.slice(0, 30)}` : null,
|
|
3302
|
+
f.min != null ? `min:${f.min}` : null,
|
|
3303
|
+
f.max != null ? `max:${f.max}` : null,
|
|
3304
|
+
].filter(Boolean).join(', ') || '—';
|
|
3305
|
+
lines.push(`| ${f.key ?? f.fieldKey ?? '?'} | ${f.type ?? f.dataType ?? '?'} | ${f.required ? '✅' : '—'} | ${constraints} | ${(f.notes ?? '').slice(0, 60)} |`);
|
|
3306
|
+
}
|
|
3307
|
+
if (entityFields.length > 50)
|
|
3308
|
+
lines.push(`| … | ${entityFields.length - 50} more | | | |`);
|
|
3309
|
+
lines.push(``);
|
|
3310
|
+
}
|
|
3311
|
+
lines.push(`## Test Implications`);
|
|
3312
|
+
lines.push(`- Required fields: ${fields.filter((f) => f.required).length} — each needs a missing-value test`);
|
|
3313
|
+
lines.push(`- Pattern-constrained fields: ${fields.filter((f) => f.pattern).length} — test valid/invalid pattern inputs`);
|
|
3314
|
+
lines.push(`- Numeric fields: ${fields.filter((f) => ['number', 'integer', 'float'].includes(f.type ?? '')).length} — test min/max boundary values`);
|
|
3315
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'FIELD_CATALOG', lines.join('\n'), {
|
|
3316
|
+
summary: `Field catalog: ${fields.length} fields across ${byEntity.size} entities`,
|
|
3317
|
+
tags: ['fields', 'catalog', 'data-model', 'constraints'],
|
|
3318
|
+
source: 'AUTO',
|
|
3319
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
3320
|
+
});
|
|
3321
|
+
}
|
|
3322
|
+
/**
|
|
3323
|
+
* GAP-SF-21: HEALING_LOG — dedicated skill file tracking healing events (element breaks + auto-fixes).
|
|
3324
|
+
* Separates healing telemetry from LOCATOR_STRATEGY so it can be queried independently.
|
|
3325
|
+
* Refreshed after self-healing events.
|
|
3326
|
+
*/
|
|
3327
|
+
async function refreshHealingLogSkillFile(tenantId, projectId) {
|
|
3328
|
+
// Check per-project skill file config
|
|
3329
|
+
const sfConfig = await loadSkillFileConfigs(tenantId, projectId).then((m) => m.get('HEALING_LOG'));
|
|
3330
|
+
if (sfConfig?.enabled === false) {
|
|
3331
|
+
return;
|
|
3332
|
+
}
|
|
3333
|
+
const existing = await app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'HEALING_LOG').catch(() => null);
|
|
3334
|
+
if (existing && (existing.approvedBy || existing.source === 'MANUAL'))
|
|
3335
|
+
return;
|
|
3336
|
+
// Read current LOCATOR_STRATEGY to extract healing annotations
|
|
3337
|
+
const locatorStrategy = await app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'LOCATOR_STRATEGY').catch(() => null);
|
|
3338
|
+
const locatorContent = locatorStrategy?.content ?? '';
|
|
3339
|
+
// Extract healing event lines from LOCATOR_STRATEGY (look for lines containing healing markers)
|
|
3340
|
+
const healingLines = locatorContent.split('\n').filter((l) => l.includes('<!-- healed') || l.includes('[HEALED]') || l.includes('auto-healed') ||
|
|
3341
|
+
l.toLowerCase().includes('healing') || l.includes('→') // before → after selector patterns
|
|
3342
|
+
);
|
|
3343
|
+
// Also query TestScript table for scripts with validation errors (fragile selectors)
|
|
3344
|
+
const { withTenant: wt } = await import('@taodigital/database');
|
|
3345
|
+
const fragileScripts = await wt(tenantId, (tx) => tx.testScript.findMany({
|
|
3346
|
+
where: { tenantId, projectId, validationStatus: 'FAIL' },
|
|
3347
|
+
select: { id: true, language: true, validationErrors: true, testCaseId: true },
|
|
3348
|
+
take: 50,
|
|
3349
|
+
orderBy: { id: 'desc' },
|
|
3350
|
+
})).catch(() => []);
|
|
3351
|
+
const healingSummary = healingLines.length > 0
|
|
3352
|
+
? healingLines.slice(0, 50).join('\n')
|
|
3353
|
+
: '> No healing events recorded yet.';
|
|
3354
|
+
const fragileList = fragileScripts.length > 0
|
|
3355
|
+
? fragileScripts
|
|
3356
|
+
.map((s) => `- [${s.language}] caseId=${s.testCaseId}: ${(s.validationErrors ?? '').slice(0, 120)}`)
|
|
3357
|
+
.join('\n')
|
|
3358
|
+
: '> No fragile scripts detected.';
|
|
3359
|
+
const content = [
|
|
3360
|
+
'# Healing Log',
|
|
3361
|
+
'',
|
|
3362
|
+
'## Purpose',
|
|
3363
|
+
'Tracks auto-healing events — when locators break and how they were fixed.',
|
|
3364
|
+
'Use this context to avoid regenerating fragile selectors.',
|
|
3365
|
+
'',
|
|
3366
|
+
'## Recent Healing Events',
|
|
3367
|
+
healingSummary,
|
|
3368
|
+
'',
|
|
3369
|
+
'## Currently Fragile Scripts (validation=FAIL)',
|
|
3370
|
+
fragileList,
|
|
3371
|
+
'',
|
|
3372
|
+
'## Healing Guidance',
|
|
3373
|
+
'- Prefer `data-testid` locators — they survive refactors.',
|
|
3374
|
+
'- ARIA role + name is second choice: `getByRole("button", { name: "Submit" })`.',
|
|
3375
|
+
'- Avoid: XPath, nth-child, auto-generated class names.',
|
|
3376
|
+
'- When a locator heals, the new selector is marked `[HEALED]` in LOCATOR_STRATEGY.',
|
|
3377
|
+
].join('\n');
|
|
3378
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'HEALING_LOG', content, {
|
|
3379
|
+
summary: `Healing log: ${healingLines.length} events, ${fragileScripts.length} fragile scripts`,
|
|
3380
|
+
source: 'AUTO',
|
|
3381
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
3382
|
+
validationStatus: 'PASS',
|
|
3383
|
+
staleStatus: 'CURRENT',
|
|
3384
|
+
});
|
|
3385
|
+
}
|
|
3386
|
+
/**
|
|
3387
|
+
* GAP-SF-22: LOCATOR_INVENTORY — per-screen map of actual extracted locators.
|
|
3388
|
+
* Sourced from TestScript rows where language ends in ':locators'.
|
|
3389
|
+
* Different from LOCATOR_STRATEGY which contains strategy guidance only.
|
|
3390
|
+
* Refreshed after locator extraction runs.
|
|
3391
|
+
*/
|
|
3392
|
+
async function refreshLocatorInventorySkillFile(tenantId, projectId) {
|
|
3393
|
+
// Check per-project skill file config
|
|
3394
|
+
const sfConfig = await loadSkillFileConfigs(tenantId, projectId).then((m) => m.get('LOCATOR_INVENTORY'));
|
|
3395
|
+
if (sfConfig?.enabled === false) {
|
|
3396
|
+
return;
|
|
3397
|
+
}
|
|
3398
|
+
const existing = await app_brain_1.AppBrain.getSkillFile(tenantId, projectId, 'LOCATOR_INVENTORY').catch(() => null);
|
|
3399
|
+
if (existing && (existing.approvedBy || existing.source === 'MANUAL'))
|
|
3400
|
+
return;
|
|
3401
|
+
const { withTenant: wt } = await import('@taodigital/database');
|
|
3402
|
+
// Locators stored as TestScript rows with language ending in ':locators'
|
|
3403
|
+
const locatorScripts = await wt(tenantId, (tx) => tx.testScript.findMany({
|
|
3404
|
+
where: {
|
|
3405
|
+
tenantId, projectId,
|
|
3406
|
+
language: { endsWith: ':locators' },
|
|
3407
|
+
},
|
|
3408
|
+
select: { id: true, language: true, code: true, testCaseId: true },
|
|
3409
|
+
take: 200,
|
|
3410
|
+
orderBy: { id: 'desc' },
|
|
3411
|
+
})).catch(() => []);
|
|
3412
|
+
// Also query screens for mapping testCaseId → screenName
|
|
3413
|
+
const testCases = await wt(tenantId, (tx) => tx.testCase.findMany({
|
|
3414
|
+
where: { tenantId, projectId },
|
|
3415
|
+
select: { id: true, title: true, ref: true, screenId: true,
|
|
3416
|
+
screen: { select: { name: true, url: true } } },
|
|
3417
|
+
take: 500,
|
|
3418
|
+
})).catch(() => []);
|
|
3419
|
+
const caseMap = new Map(testCases.map((tc) => [tc.id, tc]));
|
|
3420
|
+
if (locatorScripts.length === 0) {
|
|
3421
|
+
const content = [
|
|
3422
|
+
'# Locator Inventory',
|
|
3423
|
+
'',
|
|
3424
|
+
'> No extracted locators yet. Run locator extraction on test cases to populate this file.',
|
|
3425
|
+
'> Once populated, this file maps each screen/test case to its stable selectors.',
|
|
3426
|
+
].join('\n');
|
|
3427
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'LOCATOR_INVENTORY', content, {
|
|
3428
|
+
summary: 'Locator inventory: empty (no locators extracted yet)',
|
|
3429
|
+
source: 'AUTO',
|
|
3430
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
3431
|
+
validationStatus: 'WARN',
|
|
3432
|
+
staleStatus: 'CURRENT',
|
|
3433
|
+
});
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
// Group by screen
|
|
3437
|
+
const byScreen = new Map();
|
|
3438
|
+
for (const ls of locatorScripts) {
|
|
3439
|
+
const tc = caseMap.get(ls.testCaseId);
|
|
3440
|
+
const screenName = tc?.screen?.name ?? 'Unknown Screen';
|
|
3441
|
+
const screenUrl = tc?.screen?.url ?? '';
|
|
3442
|
+
if (!byScreen.has(screenName))
|
|
3443
|
+
byScreen.set(screenName, { screenName, url: screenUrl, entries: [] });
|
|
3444
|
+
byScreen.get(screenName).entries.push({
|
|
3445
|
+
ref: tc?.ref ?? ls.testCaseId.slice(0, 8),
|
|
3446
|
+
title: tc?.title ?? 'Untitled',
|
|
3447
|
+
locators: (ls.code ?? '').slice(0, 600),
|
|
3448
|
+
});
|
|
3449
|
+
}
|
|
3450
|
+
const sections = [...byScreen.entries()].map(([screenName, { url, entries }]) => {
|
|
3451
|
+
const header = `## ${screenName}${url ? ` (${url})` : ''}`;
|
|
3452
|
+
const rows = entries.map(e => `### ${e.ref}: ${e.title}\n\`\`\`\n${e.locators}\n\`\`\``).join('\n\n');
|
|
3453
|
+
return `${header}\n\n${rows}`;
|
|
3454
|
+
});
|
|
3455
|
+
const content = [
|
|
3456
|
+
'# Locator Inventory',
|
|
3457
|
+
'',
|
|
3458
|
+
`> ${locatorScripts.length} locator sets extracted across ${byScreen.size} screens.`,
|
|
3459
|
+
'> Use these stable selectors when generating or patching test scripts.',
|
|
3460
|
+
'',
|
|
3461
|
+
...sections,
|
|
3462
|
+
].join('\n');
|
|
3463
|
+
await app_brain_1.AppBrain.upsertSkillFile(tenantId, projectId, 'LOCATOR_INVENTORY', content, {
|
|
3464
|
+
summary: `Locator inventory: ${locatorScripts.length} sets, ${byScreen.size} screens`,
|
|
3465
|
+
source: 'AUTO',
|
|
3466
|
+
approvalStatus: sfConfig?.requireApproval ? 'NEEDS_REVIEW' : 'PENDING',
|
|
3467
|
+
validationStatus: 'PASS',
|
|
3468
|
+
staleStatus: 'CURRENT',
|
|
3469
|
+
});
|
|
3470
|
+
}
|