@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.
Files changed (76) hide show
  1. package/README.md +99 -2
  2. package/dist/api-test-agent.d.ts +126 -0
  3. package/dist/api-test-agent.js +267 -0
  4. package/dist/artifact-intel-agent.d.ts +11 -0
  5. package/dist/artifact-intel-agent.js +152 -0
  6. package/dist/coverage-gap-agent.d.ts +25 -0
  7. package/dist/coverage-gap-agent.js +269 -0
  8. package/dist/coverage-map-agent.d.ts +15 -0
  9. package/dist/coverage-map-agent.js +255 -0
  10. package/dist/crawl-diff-agent.d.ts +27 -0
  11. package/dist/crawl-diff-agent.js +412 -0
  12. package/dist/data-agent.d.ts +101 -0
  13. package/dist/data-agent.js +2284 -0
  14. package/dist/document-agent.d.ts +10 -0
  15. package/dist/document-agent.js +32 -0
  16. package/dist/enhancement-agent.d.ts +48 -0
  17. package/dist/enhancement-agent.js +342 -0
  18. package/dist/field-detector-agent.d.ts +35 -0
  19. package/dist/field-detector-agent.js +220 -0
  20. package/dist/flow-agent.d.ts +130 -0
  21. package/dist/flow-agent.js +169 -0
  22. package/dist/github-agent.d.ts +18 -0
  23. package/dist/github-agent.js +39 -0
  24. package/dist/github-write-agent.d.ts +67 -0
  25. package/dist/github-write-agent.js +249 -0
  26. package/dist/grouping-agent.d.ts +18 -0
  27. package/dist/grouping-agent.js +338 -0
  28. package/dist/healing-agent.d.ts +73 -0
  29. package/dist/healing-agent.js +137 -0
  30. package/dist/index.d.ts +36 -0
  31. package/dist/index.js +67 -0
  32. package/dist/integration-test-agent.d.ts +118 -0
  33. package/dist/integration-test-agent.js +165 -0
  34. package/dist/label-assignment-agent.d.ts +15 -0
  35. package/dist/label-assignment-agent.js +97 -0
  36. package/dist/llm-config.d.ts +38 -0
  37. package/dist/llm-config.js +287 -0
  38. package/dist/llm-mock.d.ts +77 -0
  39. package/dist/llm-mock.js +192 -0
  40. package/dist/llm.d.ts +58 -0
  41. package/dist/llm.js +438 -0
  42. package/dist/nlp-agent.d.ts +208 -0
  43. package/dist/nlp-agent.js +340 -0
  44. package/dist/orchestrator.d.ts +285 -0
  45. package/dist/orchestrator.js +141 -0
  46. package/dist/plan-agent.d.ts +4 -0
  47. package/dist/plan-agent.js +200 -0
  48. package/dist/repo-scan-agent.d.ts +189 -0
  49. package/dist/repo-scan-agent.js +2032 -0
  50. package/dist/requirements-agent.d.ts +59 -0
  51. package/dist/requirements-agent.js +641 -0
  52. package/dist/scenario-agent.d.ts +20 -0
  53. package/dist/scenario-agent.js +123 -0
  54. package/dist/script-agent.d.ts +447 -0
  55. package/dist/script-agent.js +11813 -0
  56. package/dist/security-test-agent.d.ts +111 -0
  57. package/dist/security-test-agent.js +141 -0
  58. package/dist/skill-file-agent.d.ts +264 -0
  59. package/dist/skill-file-agent.js +3470 -0
  60. package/dist/step-coverage.d.ts +46 -0
  61. package/dist/step-coverage.js +288 -0
  62. package/dist/step-utils.d.ts +2 -0
  63. package/dist/step-utils.js +62 -0
  64. package/dist/test-case-agent.d.ts +206 -0
  65. package/dist/test-case-agent.js +2141 -0
  66. package/dist/test-import-agent.d.ts +148 -0
  67. package/dist/test-import-agent.js +3235 -0
  68. package/dist/test-script-agent.d.ts +16 -0
  69. package/dist/test-script-agent.js +48 -0
  70. package/dist/token-pricing.d.ts +14 -0
  71. package/dist/token-pricing.js +62 -0
  72. package/dist/types.d.ts +232 -0
  73. package/dist/types.js +7 -0
  74. package/dist/visual-agent.d.ts +114 -0
  75. package/dist/visual-agent.js +261 -0
  76. package/package.json +26 -4
@@ -0,0 +1,3235 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseTestCases = parseTestCases;
7
+ exports.headerSignature = headerSignature;
8
+ exports.inferColumnMapping = inferColumnMapping;
9
+ exports.saveColumnMapping = saveColumnMapping;
10
+ exports.loadColumnMapping = loadColumnMapping;
11
+ exports.sanitizeParsedCase = sanitizeParsedCase;
12
+ exports.extractTabularHeaders = extractTabularHeaders;
13
+ exports.scenarioContextKey = scenarioContextKey;
14
+ exports.parseTestCasesWithCache = parseTestCasesWithCache;
15
+ exports.parseJsonWithRepair = parseJsonWithRepair;
16
+ exports.extractFzeRawPairs = extractFzeRawPairs;
17
+ exports.isFunctionizeFormat = isFunctionizeFormat;
18
+ exports.extractGroundedScenarioCandidates = extractGroundedScenarioCandidates;
19
+ exports.extractScenarios = extractScenarios;
20
+ exports.buildSeleniumHints = buildSeleniumHints;
21
+ exports.annotateWithSeleniumHints = annotateWithSeleniumHints;
22
+ const node_crypto_1 = __importDefault(require("node:crypto"));
23
+ const app_brain_1 = require("@taodigital/app-brain");
24
+ const core_1 = require("@taodigital/core");
25
+ const llm_js_1 = require("./llm.js");
26
+ const llm_config_js_1 = require("./llm-config.js");
27
+ /**
28
+ * Best-effort conversion of a Selenium XPath to a semantic Playwright/CSS selector hint.
29
+ * Returns the converted string if conversion is confident, otherwise returns null.
30
+ */
31
+ function tryConvertXPathToSemantic(xpath) {
32
+ if (!xpath || !xpath.trim().startsWith('/'))
33
+ return null;
34
+ // data-testid: //[*][@data-testid='X'] or //*[@data-testid="X"]
35
+ const testIdMatch = xpath.match(/@data-testid\s*=\s*["']([^"']+)["']/);
36
+ if (testIdMatch)
37
+ return `[data-testid="${testIdMatch[1]}"]`;
38
+ // aria-label: //*[@aria-label='X']
39
+ const ariaLabelMatch = xpath.match(/@aria-label\s*=\s*["']([^"']+)["']/);
40
+ if (ariaLabelMatch)
41
+ return `[aria-label="${ariaLabelMatch[1]}"]`;
42
+ // placeholder: //input[@placeholder='X']
43
+ const placeholderMatch = xpath.match(/@placeholder\s*=\s*["']([^"']+)["']/);
44
+ if (placeholderMatch)
45
+ return `[placeholder="${placeholderMatch[1]}"]`;
46
+ // id: //*[@id='X'] or //input[@id='X']
47
+ const idMatch = xpath.match(/@id\s*=\s*["']([^"']+)["']/);
48
+ if (idMatch)
49
+ return `#${idMatch[1]}`;
50
+ // name: //*[@name='X']
51
+ const nameMatch = xpath.match(/@name\s*=\s*["']([^"']+)["']/);
52
+ if (nameMatch)
53
+ return `[name="${nameMatch[1]}"]`;
54
+ // text content: //button[text()='X'] or //a[contains(text(),'X')]
55
+ const textExactMatch = xpath.match(/\[text\(\)\s*=\s*["']([^"']{1,60})["']\]/);
56
+ if (textExactMatch)
57
+ return `text=${textExactMatch[1]}`;
58
+ const textContainsMatch = xpath.match(/contains\s*\(\s*text\s*\(\s*\)\s*,\s*["']([^"']{1,60})["']\)/);
59
+ if (textContainsMatch)
60
+ return `text=${textContainsMatch[1]}`;
61
+ // CSS class (only if simple single class): //*[contains(@class,'submit-btn')]
62
+ const classMatch = xpath.match(/contains\s*\(\s*@class\s*,\s*["']([a-zA-Z0-9_-]+)["']\)/);
63
+ if (classMatch)
64
+ return `.${classMatch[1]}`;
65
+ return null; // XPath too complex to convert; keep as-is with warning
66
+ }
67
+ function platformPrefix(platform) {
68
+ switch ((platform ?? '').trim().toLowerCase()) {
69
+ case 'katalon': return 'TC-KAT-';
70
+ case 'qmetry': return 'TC-QMT-';
71
+ case 'testrail': return 'TC-TRL-';
72
+ case 'xray': return 'TC-XRY-';
73
+ case 'zephyr': return 'TC-ZEP-';
74
+ case 'functionize': return 'TC-FZE-';
75
+ case 'azure devops': return 'TC-AZR-';
76
+ case 'practitest': return 'TC-PTC-';
77
+ case 'qtest': return 'TC-QTS-';
78
+ case 'other':
79
+ case 'generic':
80
+ case '':
81
+ return 'TC-IMP-';
82
+ default: return 'TC-IMP-';
83
+ }
84
+ }
85
+ function normalizeRef(raw, platform) {
86
+ const prefix = platformPrefix(platform);
87
+ const s = raw.trim();
88
+ if (!s)
89
+ return s;
90
+ if (/^TC-[A-Z]{2,6}-/.test(s))
91
+ return s;
92
+ const fzeM = s.match(/^fze_([a-f0-9]+)/i);
93
+ if (fzeM)
94
+ return `${prefix}${fzeM[1].slice(0, 8).toUpperCase()}`;
95
+ // TestRail: C12345
96
+ if (/^C\d+$/.test(s))
97
+ return `${prefix}${s.slice(1)}`;
98
+ // Katalon: TC-123 or TC_123
99
+ if (/^TC[-_]\d+$/i.test(s))
100
+ return `${prefix}${s.replace(/^TC[-_]/i, '')}`;
101
+ // QMetry: QT-123 or QM-123
102
+ if (/^Q[TM]-\d+$/i.test(s))
103
+ return `${prefix}${s.replace(/^[A-Z]+-/i, '')}`;
104
+ // Zephyr Scale commonly exports project test keys such as ABC-T123.
105
+ if ((platform ?? '').trim().toLowerCase() === 'zephyr' && /^[A-Z][A-Z0-9]+-T\d+$/i.test(s))
106
+ return s;
107
+ // Xray and Azure DevOps often preserve Jira/work-item keys; keep stable source keys as-is.
108
+ if (/^[A-Z][A-Z0-9]+-\d+$/i.test(s))
109
+ return s;
110
+ // Pure numeric ID
111
+ if (/^\d+$/.test(s))
112
+ return `${prefix}${s}`;
113
+ return s;
114
+ }
115
+ function sanitizeInlineKey(raw) {
116
+ const cleaned = raw.trim()
117
+ .replace(/^["']|["']$/g, '')
118
+ .replace(/^(test\s*)?(data|input|parameter|variable)\s*[:#-]?\s*/i, '')
119
+ .replace(/[^\w\s.-]/g, ' ')
120
+ .trim();
121
+ if (/^[A-Za-z][A-Za-z0-9]*$/.test(cleaned)) {
122
+ return (cleaned.charAt(0).toLowerCase() + cleaned.slice(1)).slice(0, 60);
123
+ }
124
+ const camel = cleaned
125
+ .split(/[\s.-]+/)
126
+ .filter(Boolean)
127
+ .map((part, i) => i === 0 ? part.toLowerCase() : part.charAt(0).toUpperCase() + part.slice(1))
128
+ .join('');
129
+ return camel.replace(/[^\w]/g, '').slice(0, 60) || 'testData';
130
+ }
131
+ function normalizeImportedDataValue(key, value) {
132
+ if (value == null)
133
+ return value;
134
+ const lower = key.toLowerCase();
135
+ if (/password|passwd|pwd/.test(lower))
136
+ return '[env:TEST_USER_PASSWORD]';
137
+ if (/api.?key|access.?key/.test(lower))
138
+ return '[env:TEST_API_KEY]';
139
+ if (/token|bearer|jwt/.test(lower))
140
+ return '[env:TEST_TOKEN]';
141
+ if (/secret|clientsecret/.test(lower))
142
+ return '[env:TEST_SECRET]';
143
+ return value;
144
+ }
145
+ function parseImportedTestData(raw) {
146
+ if (raw == null)
147
+ return undefined;
148
+ if (typeof raw === 'object' && !Array.isArray(raw)) {
149
+ const flat = {};
150
+ for (const [k, v] of Object.entries(flattenImportData(raw))) {
151
+ if (v == null || v === '')
152
+ continue;
153
+ const key = sanitizeInlineKey(k);
154
+ flat[key] = normalizeImportedDataValue(key, v);
155
+ }
156
+ return Object.keys(flat).length > 0 ? flat : undefined;
157
+ }
158
+ const text = String(raw).trim();
159
+ if (!text)
160
+ return undefined;
161
+ try {
162
+ const parsed = JSON.parse(text);
163
+ const obj = parseImportedTestData(parsed);
164
+ if (obj)
165
+ return obj;
166
+ }
167
+ catch { /* not JSON */ }
168
+ const out = {};
169
+ const parts = text.split(/\s*(?:\||;|\n|,(?=\s*[\w .-]+\s*[:=]))\s*/).map(p => p.trim()).filter(Boolean);
170
+ for (const part of parts) {
171
+ const m = part.match(/^([^:=]{1,80})\s*[:=]\s*(.+)$/);
172
+ if (!m)
173
+ continue;
174
+ const key = sanitizeInlineKey(m[1]);
175
+ const value = m[2].trim().replace(/^["']|["']$/g, '');
176
+ if (key && value)
177
+ out[key] = normalizeImportedDataValue(key, value);
178
+ }
179
+ if (Object.keys(out).length > 0)
180
+ return out;
181
+ return { testData: text };
182
+ }
183
+ function flattenImportData(obj, prefix = '') {
184
+ const flat = {};
185
+ for (const [k, v] of Object.entries(obj)) {
186
+ const key = prefix ? `${prefix}.${k}` : k;
187
+ if (v && typeof v === 'object' && !Array.isArray(v))
188
+ Object.assign(flat, flattenImportData(v, key));
189
+ else if (!Array.isArray(v))
190
+ flat[key] = v;
191
+ }
192
+ return flat;
193
+ }
194
+ function mergeTestDataInline(tc, raw) {
195
+ const parsed = parseImportedTestData(raw);
196
+ if (!parsed)
197
+ return;
198
+ const next = { ...(tc.testDataInline ?? {}) };
199
+ for (const [k, v] of Object.entries(parsed)) {
200
+ if (next[k] === undefined || next[k] === '')
201
+ next[k] = v;
202
+ }
203
+ tc.testDataInline = next;
204
+ }
205
+ async function parseTestCases(content, format, tenantId, mapping, scenarioContext, filePath, sourcePlatform) {
206
+ if (format === 'GHERKIN')
207
+ return parseGherkin(content);
208
+ if (format === 'EXCEL' || format === 'CSV') {
209
+ // If this spreadsheet is actually a Functionize recording (e.g. transposed 2-column or has pageinit),
210
+ // parse it as Functionize test cases directly instead of failing column mapping.
211
+ const fzeCheck = tryParseFzeExcel(content, sourcePlatform);
212
+ if (fzeCheck && fzeCheck.cases.length > 0)
213
+ return fzeCheck;
214
+ const raw = parseTabular(content, mapping, sourcePlatform);
215
+ return {
216
+ cases: raw.cases.map(c => sanitizeParsedCase(c)).filter((c) => c !== null),
217
+ errors: raw.errors,
218
+ };
219
+ }
220
+ if (format === 'SPEC')
221
+ return parseSpecFile(content, filePath);
222
+ if (format === 'FUNCTIONIZE') {
223
+ const jsonResult = tryParseJsonCases(content, 'functionize');
224
+ if (jsonResult)
225
+ return jsonResult;
226
+ const xlResult = tryParseFzeExcel(content, sourcePlatform);
227
+ if (xlResult)
228
+ return xlResult;
229
+ return { cases: [], errors: ['No Functionize test cases found in this file'] };
230
+ }
231
+ // Try direct JSON parse before invoking AI
232
+ const jsonResult = tryParseJsonCases(content, sourcePlatform);
233
+ if (jsonResult)
234
+ return jsonResult;
235
+ return aiExtract(content, tenantId, scenarioContext);
236
+ }
237
+ // ─── Column mapping — AI inference + per-tenant persistence ──────────────────
238
+ /** Stable 8-char hex key for a header set (order-independent). */
239
+ function headerSignature(headers) {
240
+ const normalized = [...headers].map((h) => h.trim().toLowerCase()).sort().join('|');
241
+ return node_crypto_1.default.createHash('sha256').update(normalized).digest('hex').slice(0, 8);
242
+ }
243
+ /**
244
+ * Load or infer a column mapping for the given headers.
245
+ * Order: saved → AI inference → heuristic synonym fallback.
246
+ */
247
+ async function inferColumnMapping(headers, sampleRows, tenantId) {
248
+ const signature = headerSignature(headers);
249
+ // 1. Check saved mapping
250
+ const saved = await loadColumnMapping(tenantId, signature);
251
+ if (saved)
252
+ return { mapping: saved, source: 'saved', signature, headers, sampleRows };
253
+ // 2. Try AI inference
254
+ const llmCfg = await (0, llm_config_js_1.getTaskAgentLLMConfig)(tenantId, 'appBrain').catch(() => null);
255
+ if (llmCfg) {
256
+ try {
257
+ const mapping = await _aiInferMapping(headers, sampleRows, llmCfg);
258
+ return { mapping, source: 'ai', signature, headers, sampleRows };
259
+ }
260
+ catch { /* fall through to heuristic */ }
261
+ }
262
+ // 3. Heuristic synonyms (existing colIdx logic)
263
+ const mapping = _heuristicMapping(headers);
264
+ return { mapping, source: 'heuristic', signature, headers, sampleRows };
265
+ }
266
+ async function _aiInferMapping(headers, sampleRows, llmCfg) {
267
+ const headerList = headers.map((h, i) => `${i}: "${h}"`).join(', ');
268
+ const sampleText = sampleRows.slice(0, 3).map((r, ri) => `Row ${ri + 1}: ` + headers.map((_, i) => `"${(r[i] ?? '').slice(0, 60)}"`).join(', ')).join('\n');
269
+ const COLUMN_MAP_DEFAULT = `You are a QA tool that maps spreadsheet columns to test-case fields.
270
+ Return ONLY a JSON object — no explanation, no markdown fences.
271
+ Keys are field names, values are zero-based column indices (integers). Omit fields with no good match.
272
+
273
+ Fields: title, ref, steps, expected, priority, kind, preconditions, data, tags, section, actualResult, comment, stepId, screenshotUrl, status
274
+
275
+ Field descriptions:
276
+ - title: test case name/description
277
+ - ref: test case ID or reference number
278
+ - steps: test step actions/instructions
279
+ - expected: expected result per step
280
+ - priority: severity or priority level (Critical/High/Medium/Low)
281
+ - kind: test type (Functional/Regression/Smoke/etc)
282
+ - preconditions: setup prerequisites
283
+ - data: test input data or credentials
284
+ - tags: labels or categories
285
+ - section: module, suite, or folder
286
+ - actualResult: what actually happened during test execution
287
+ - comment: notes, observations, or remarks
288
+ - stepId: step number or identifier (Step 1, Step 2, etc)
289
+ - screenshotUrl: URL or path to a screenshot file
290
+ - status: test execution status (Passed/Failed/Skipped/Blocked)
291
+
292
+ Example output: {"title":0,"ref":1,"steps":3,"expected":4,"priority":5,"actualResult":6,"comment":7}`;
293
+ const system = await (0, core_1.getAgentPrompt)('test-import-column-map').catch(() => null) ?? COLUMN_MAP_DEFAULT;
294
+ const user = `Headers (index: name): ${headerList}
295
+
296
+ Sample data:
297
+ ${sampleText}
298
+
299
+ Map these headers to test-case fields.`;
300
+ const seed = (0, llm_js_1.deterministicSeed)(`col-map:${headers.join('|').slice(0, 500)}`);
301
+ const { text } = await (0, llm_js_1.askTracked)(system, user, llmCfg, 256, undefined, { temperature: 0, seed });
302
+ // Parse — try direct, then bracket slice
303
+ let obj;
304
+ try {
305
+ obj = JSON.parse(text);
306
+ }
307
+ catch {
308
+ const s = text.indexOf('{'), e = text.lastIndexOf('}');
309
+ if (s < 0 || e <= s)
310
+ throw new Error('No JSON object in LLM response');
311
+ obj = JSON.parse(text.slice(s, e + 1));
312
+ }
313
+ // Validate: values must be non-negative integers within header range
314
+ const mapping = {};
315
+ const fields = ['title', 'ref', 'steps', 'expected', 'priority', 'kind', 'preconditions', 'data', 'tags', 'section', 'actualResult', 'comment', 'stepId', 'screenshotUrl', 'status'];
316
+ for (const f of fields) {
317
+ const v = obj[f];
318
+ if (typeof v === 'number' && Number.isInteger(v) && v >= 0 && v < headers.length) {
319
+ mapping[f] = v;
320
+ }
321
+ }
322
+ return mapping;
323
+ }
324
+ function _heuristicMapping(headers) {
325
+ const h = headers.map((s) => s.trim().toLowerCase());
326
+ const colIdx = (names) => {
327
+ for (const n of names) {
328
+ const i = h.indexOf(n);
329
+ if (i >= 0)
330
+ return i;
331
+ }
332
+ return undefined;
333
+ };
334
+ return {
335
+ title: colIdx(['title', 'name', 'test case', 'test name', 'scenario', 'tc description', 'description', 'test description', 'test case name', 'test case title', 'summary', 'test summary']),
336
+ ref: colIdx(['ref', 'id', 'tc_id #', 'tc id', 'tc #', 'tc_id', 'test id', 'no', 'case id', 'key', 'test case id', 'tc_ref', 'work item id']),
337
+ steps: colIdx(['steps', 'step', 'actions', 'test steps', 'action', 'step description', 'test action']),
338
+ expected: colIdx(['expected', 'expected result', 'expected outcome', 'result', 'expected behavior', 'pass criteria']),
339
+ priority: colIdx(['priority', 'severity', 'importance', 'criticality']),
340
+ kind: colIdx(['type', 'kind', 'category', 'test type', 'test category', 'case type']),
341
+ preconditions: colIdx(['preconditions', 'prerequisites', 'precondition', 'pre-conditions', 'setup', 'pre-requisites']),
342
+ data: colIdx(['test data', 'data', 'input data', 'test inputs', 'inputs', 'credentials', 'test credentials']),
343
+ tags: colIdx(['tags', 'label', 'labels', 'tag', 'keywords', 'markers']),
344
+ section: colIdx(['section', 'folder', 'module', 'group', 'component', 'module/suite', 'suite', 'test suite', 'area', 'feature', 'area path', 'test suite folder', 'subsystem']),
345
+ actualResult: colIdx(['actual result', 'Actual Result', 'Actual Outcome', 'Actual Behavior', 'actual', 'actual outcome', 'result actual', 'actual behavior', 'observed', 'observed result', 'run result', 'execution result']),
346
+ comment: colIdx(['comment', 'comments', 'notes', 'note', 'remarks', 'remark', 'observation', 'description', 'review notes', 'tester notes']),
347
+ stepId: colIdx(['step id', 'step no', 'step #', 'step number', 'no.', '#', 'seq', 'step_no', 'stepno', 'step_id', 'step num']),
348
+ screenshotUrl: colIdx(['screenshot', 'screenshot url', 'screenshot_url', 'image', 'attachment', 'evidence', 'screen capture', 'screen shot']),
349
+ status: colIdx(['status', 'test status', 'execution status', 'run status', 'result status', 'pass/fail', 'passed/failed', 'state']),
350
+ };
351
+ }
352
+ async function saveColumnMapping(tenantId, signature, mapping) {
353
+ await app_brain_1.AppBrain.saveImportMapping(tenantId, signature, mapping);
354
+ }
355
+ async function loadColumnMapping(tenantId, signature) {
356
+ return app_brain_1.AppBrain.loadImportMapping(tenantId, signature);
357
+ }
358
+ // Generic/useless test case titles that should be replaced with inferred content.
359
+ const GENERIC_TITLE_RE = /^(test case \d+|untitled|test|home|index|default|unnamed|new test|\d+|tc[-_]?\d*|scenario \d+|step \d+)$/i;
360
+ /**
361
+ * Infer a meaningful title from test content when the stored title is generic.
362
+ * Priority: Gherkin Scenario line → expectedResult first line → first meaningful step action.
363
+ */
364
+ function inferTitle(steps, expectedResult, gherkin) {
365
+ // 1. Gherkin scenario line (already curated by the recording/authoring tool)
366
+ if (gherkin) {
367
+ const m = gherkin.match(/^Scenario(?:\s+Outline)?:\s*(.+)/m);
368
+ const candidate = m?.[1]?.trim() ?? '';
369
+ if (candidate && !GENERIC_TITLE_RE.test(candidate))
370
+ return candidate.slice(0, 200);
371
+ }
372
+ // 2. expectedResult — strip "Verify:" prefix, use first meaningful line
373
+ if (expectedResult && expectedResult.length > 5) {
374
+ const line = expectedResult.split('\n')[0].trim().replace(/^(verify|expected|assert|check|confirm):\s*/i, '').replace(/^"(.+)"$/, '$1').trim();
375
+ if (line.length > 5 && line.length < 160 && !GENERIC_TITLE_RE.test(line) && !/\{\{fze\./i.test(line)) {
376
+ return `Verify ${line}`;
377
+ }
378
+ }
379
+ // 3. First meaningful step action (skip navigate/script/switch boilerplate)
380
+ const skip = /^(navigate to:|execute script|switch to context|\[conditional)/i;
381
+ const meaningful = steps.find(s => s.action && s.action.length > 8 && !skip.test(s.action));
382
+ if (meaningful)
383
+ return meaningful.action.split('\n')[0].trim().slice(0, 160);
384
+ return '';
385
+ }
386
+ /**
387
+ * Automatically expands truncated or superficial test steps (e.g. 1-step table row imports
388
+ * or 3-step generic boilerplate) into 4 to 5 actionable, sequential steps.
389
+ */
390
+ function expandTruncatedSteps(title, existingSteps, preconditions, expectedResult, kind) {
391
+ if (existingSteps.length >= 4)
392
+ return existingSteps;
393
+ const cleanTitle = title
394
+ .replace(/^(?:verify|validate|check|test|ensure|confirm|assert|block|prevent)\s+/i, '')
395
+ .trim();
396
+ const expRes = expectedResult || cleanTitle;
397
+ const isNegative = kind === 'NEGATIVE' || /\b(block|prevent|reject|deny|invalid|error|fail)\b/i.test(title);
398
+ // Case 1: 1-step test case (typical of business rule table rows)
399
+ if (existingSteps.length === 1) {
400
+ const single = existingSteps[0];
401
+ const singleAction = single.action.trim();
402
+ const singleExpected = single.expected.trim() || expRes;
403
+ if (isNegative) {
404
+ return [
405
+ {
406
+ stepId: '1',
407
+ action: `${preconditions ? `Prerequisites: ${preconditions}. ` : ''}Navigate to the relevant section or form.`,
408
+ expected: 'The page or form is displayed and ready for interaction.',
409
+ },
410
+ {
411
+ stepId: '2',
412
+ action: `Open the form or screen for ${cleanTitle}.`,
413
+ expected: 'All required fields are displayed and enabled.',
414
+ },
415
+ {
416
+ stepId: '3',
417
+ action: singleAction.startsWith('Attempt') ? singleAction : `Attempt to ${singleAction.charAt(0).toLowerCase() + singleAction.slice(1)}.`,
418
+ expected: singleExpected.length > 5 ? singleExpected : 'System detects the invalid input/condition and prevents further processing.',
419
+ },
420
+ {
421
+ stepId: '4',
422
+ action: 'Verify visual error feedback, dialog prompt, or field-level validation message.',
423
+ expected: 'A clear, descriptive error or warning message is displayed and transaction transmission is blocked.',
424
+ },
425
+ {
426
+ stepId: '5',
427
+ action: 'Enter valid corrected data or cancel the blocked operation.',
428
+ expected: 'System clears the validation error, returns to a stable state, and logs the blocked event if required.',
429
+ },
430
+ ];
431
+ }
432
+ else {
433
+ return [
434
+ {
435
+ stepId: '1',
436
+ action: `${preconditions ? `Prerequisites: ${preconditions}. ` : ''}Navigate to the relevant section or dashboard.`,
437
+ expected: 'The section or dashboard is displayed.',
438
+ },
439
+ {
440
+ stepId: '2',
441
+ action: `Open the feature or form for ${cleanTitle}.`,
442
+ expected: 'The feature or form is loaded with all applicable fields.',
443
+ },
444
+ {
445
+ stepId: '3',
446
+ action: singleAction,
447
+ expected: 'Input is accepted and field validation passes successfully.',
448
+ },
449
+ {
450
+ stepId: '4',
451
+ action: 'Submit the form or trigger the primary action.',
452
+ expected: singleExpected,
453
+ },
454
+ {
455
+ stepId: '5',
456
+ action: 'Verify the outcome, returned confirmation details, and system state.',
457
+ expected: 'A confirmation or reference is returned and the relevant system records are updated.',
458
+ },
459
+ ];
460
+ }
461
+ }
462
+ // Helper to infer realistic Virginia CVR test data for step parameters
463
+ const domainData = inferDomainTestData(cleanTitle);
464
+ // Case 2: 2-step test case -> expand to 4 concrete steps
465
+ if (existingSteps.length === 2) {
466
+ return [
467
+ {
468
+ stepId: '1',
469
+ action: `${preconditions ? `Prerequisites: ${preconditions}. ` : ''}Navigate to the relevant section.`,
470
+ expected: 'The section is displayed.',
471
+ },
472
+ {
473
+ stepId: '2',
474
+ action: existingSteps[0].action,
475
+ data: existingSteps[0].data || domainData,
476
+ expected: existingSteps[0].expected || 'Field input is validated and accepted.',
477
+ },
478
+ {
479
+ stepId: '3',
480
+ action: existingSteps[1].action,
481
+ expected: existingSteps[1].expected || expRes,
482
+ },
483
+ {
484
+ stepId: '4',
485
+ action: 'Verify the final status and system state.',
486
+ expected: 'The operation completes successfully and relevant records are updated.',
487
+ },
488
+ ];
489
+ }
490
+ // Case 3: 3-step test case -> always expand to 5 concrete steps
491
+ if (existingSteps.length === 3) {
492
+ const s1 = existingSteps[0].action.toLowerCase();
493
+ const s2 = existingSteps[1].action.toLowerCase();
494
+ const isGenericBoilerplate = (s1.includes('log in') || s1.includes('login')) && (s2.includes('navigate') || s2.includes('open'));
495
+ if (isGenericBoilerplate) {
496
+ return [
497
+ {
498
+ stepId: '1',
499
+ action: existingSteps[0].action,
500
+ expected: existingSteps[0].expected || 'User is authenticated and redirected to the dashboard.',
501
+ },
502
+ {
503
+ stepId: '2',
504
+ action: existingSteps[1].action,
505
+ expected: existingSteps[1].expected || 'Section is displayed.',
506
+ },
507
+ {
508
+ stepId: '3',
509
+ action: `Configure the parameters for ${cleanTitle} and complete all required fields.`,
510
+ data: domainData,
511
+ expected: 'Form data is populated and client-side validations confirm readiness.',
512
+ },
513
+ {
514
+ stepId: '4',
515
+ action: existingSteps[2].action,
516
+ expected: existingSteps[2].expected || expRes,
517
+ },
518
+ {
519
+ stepId: '5',
520
+ action: 'Verify the operation outcome and returned confirmation details.',
521
+ expected: 'The operation completes with confirmed status and relevant records are updated.',
522
+ },
523
+ ];
524
+ }
525
+ else {
526
+ // 3 substantive steps: wrap with prerequisite setup and post-condition audit
527
+ return [
528
+ {
529
+ stepId: '1',
530
+ action: `${preconditions ? `Prerequisites: ${preconditions}. ` : ''}Open the form for ${cleanTitle}.`,
531
+ expected: 'The form is displayed and ready for entry.',
532
+ },
533
+ {
534
+ stepId: '2',
535
+ action: existingSteps[0].action,
536
+ data: existingSteps[0].data || domainData,
537
+ expected: existingSteps[0].expected || 'Step input is validated.',
538
+ },
539
+ {
540
+ stepId: '3',
541
+ action: existingSteps[1].action,
542
+ expected: existingSteps[1].expected || 'Validation passes.',
543
+ },
544
+ {
545
+ stepId: '4',
546
+ action: existingSteps[2].action,
547
+ expected: existingSteps[2].expected || expRes,
548
+ },
549
+ {
550
+ stepId: '5',
551
+ action: 'Verify the final outcome, database updates, and system state.',
552
+ expected: 'The operation completes with confirmed status and the system state is updated.',
553
+ },
554
+ ];
555
+ }
556
+ }
557
+ return existingSteps;
558
+ }
559
+ function inferDomainTestData(_text) {
560
+ // Domain-agnostic: do not inject domain-specific test data into step templates.
561
+ // Concrete test data must come from the source document via the LLM prompt.
562
+ return undefined;
563
+ }
564
+ /**
565
+ * Coerce an untrusted (LLM- or client-supplied) object into a valid ParsedTestCase.
566
+ * Returns null when the object cannot represent a test case.
567
+ */
568
+ function sanitizeParsedCase(tc) {
569
+ if (!tc?.title || typeof tc.title !== 'string')
570
+ return null;
571
+ const rawSteps = Array.isArray(tc.steps) ? tc.steps : [];
572
+ const steps = rawSteps
573
+ .filter((s) => s && (s.action || s.expected))
574
+ .map((s, i) => ({
575
+ stepId: s.stepId ? String(s.stepId) : String(i + 1),
576
+ action: String(s.action ?? ''),
577
+ data: s.data ? String(s.data) : undefined,
578
+ expected: String(s.expected ?? ''),
579
+ actualResult: s.actualResult ? String(s.actualResult) : undefined,
580
+ comment: s.comment ? String(s.comment) : undefined,
581
+ screenshotUrl: s.screenshotUrl ? String(s.screenshotUrl) : undefined,
582
+ }));
583
+ const expectedResult = String(tc.expectedResult ?? tc.title);
584
+ if (steps.length === 0)
585
+ steps.push({ stepId: '1', action: 'Execute test case', expected: expectedResult });
586
+ // Normalize title: enforce max length, detect and replace generic fallbacks
587
+ let title = tc.title.trim().slice(0, 255);
588
+ if (GENERIC_TITLE_RE.test(title) || title.length < 3) {
589
+ const inferred = inferTitle(steps, expectedResult, tc.gherkin ? String(tc.gherkin) : undefined);
590
+ if (inferred)
591
+ title = inferred.slice(0, 255);
592
+ }
593
+ if (!title)
594
+ return null;
595
+ const normalizedKind = normalizeKind(String(tc.kind ?? ''));
596
+ const finalizedSteps = expandTruncatedSteps(title, steps, tc.preconditions ? String(tc.preconditions) : undefined, expectedResult, normalizedKind);
597
+ return {
598
+ ref: tc.ref ? String(tc.ref) : undefined,
599
+ traceTo: tc.traceTo ? String(tc.traceTo) : undefined,
600
+ caseRefs: Array.isArray(tc.caseRefs) ? tc.caseRefs.filter((r) => r && typeof r === 'object') : undefined,
601
+ title,
602
+ kind: normalizedKind,
603
+ priority: (0, core_1.normalizePriority)(String(tc.priority ?? '')) ?? 'MEDIUM',
604
+ preconditions: tc.preconditions ? String(tc.preconditions) : undefined,
605
+ steps: finalizedSteps,
606
+ expectedResult,
607
+ actualResult: tc.actualResult ? String(tc.actualResult) : undefined,
608
+ comment: tc.comment ? String(tc.comment) : undefined,
609
+ screenshotUrl: tc.screenshotUrl ? String(tc.screenshotUrl) : undefined,
610
+ status: tc.status ? String(tc.status) : undefined,
611
+ gherkin: tc.gherkin ? String(tc.gherkin) : undefined,
612
+ tags: Array.isArray(tc.tags) ? tc.tags.map(String) : [],
613
+ section: tc.section ? String(tc.section) : undefined,
614
+ // Preserve recorded form field data from FZE imports so the import route can
615
+ // promote it to a TestData row and build testDataInline in the DB.
616
+ testDataInline: tc.testDataInline && typeof tc.testDataInline === 'object' && !Array.isArray(tc.testDataInline)
617
+ ? tc.testDataInline
618
+ : undefined,
619
+ };
620
+ }
621
+ // ─── Spec file parser (Playwright / Cypress / Jest) ──────────────────────────
622
+ function titleFromPythonName(name) {
623
+ return name
624
+ .replace(/^test_?/i, '')
625
+ .replace(/[_-]+/g, ' ')
626
+ .replace(/\s+/g, ' ')
627
+ .trim()
628
+ .replace(/^./, (c) => c.toUpperCase()) || 'Python Selenium test';
629
+ }
630
+ function stripMarkdownCodeWrapper(content, filePath) {
631
+ const fence = content.match(/```(?:python|py)?\s*([\s\S]*?)```/i);
632
+ if (fence)
633
+ return fence[1].trim();
634
+ if (filePath?.endsWith('.py')) {
635
+ const headerMatch = content.match(/^#[^\n]*\n(?:\n>?[^\n]*\n)*\n([\s\S]+)$/);
636
+ if (headerMatch)
637
+ return headerMatch[1];
638
+ }
639
+ return content;
640
+ }
641
+ function pythonLocatorFieldKey(locator, fallbackIdx) {
642
+ const candidate = locator.match(/@(?:name|id|aria-label|placeholder)\s*=\s*["']([^"']+)["']/)?.[1]
643
+ ?? locator.match(/By\.(?:NAME|ID)\s*,\s*["']([^"']+)["']/)?.[1]
644
+ ?? locator.match(/#([A-Za-z][\w-]+)/)?.[1]
645
+ ?? locator.match(/\[name=["']([^"']+)["']\]/)?.[1];
646
+ return sanitizeInlineKey(candidate ?? `field${fallbackIdx}`);
647
+ }
648
+ function parsePythonSpecFile(content, filePath) {
649
+ const py = stripMarkdownCodeWrapper(content, filePath);
650
+ const lines = py.split('\n');
651
+ const cases = [];
652
+ let current = null;
653
+ let fieldIdx = 1;
654
+ const filenameTitle = titleFromPythonName((filePath ?? 'python_selenium_test.py').split('/').pop() ?? 'python_selenium_test.py');
655
+ const flush = () => {
656
+ if (!current)
657
+ return;
658
+ if (current.steps.length === 0)
659
+ current.steps.push({ action: 'Execute Python test script', expected: 'Script passes without errors' });
660
+ const expectedSteps = current.steps.filter(s => s.expected);
661
+ current.expectedResult = current.steps.some(s => s.expected)
662
+ ? (expectedSteps[expectedSteps.length - 1]?.expected ?? current.expectedResult)
663
+ : current.expectedResult;
664
+ cases.push(current);
665
+ current = null;
666
+ };
667
+ const ensureCase = (title = filenameTitle) => {
668
+ if (!current) {
669
+ current = {
670
+ title,
671
+ kind: 'E2E',
672
+ priority: 'MEDIUM',
673
+ section: 'Selenium Python',
674
+ tags: ['python', 'selenium', 'imported'],
675
+ steps: [],
676
+ expectedResult: 'Python Selenium script passes without errors',
677
+ };
678
+ }
679
+ return current;
680
+ };
681
+ for (const rawLine of lines) {
682
+ const line = rawLine.trim();
683
+ if (!line || line.startsWith('#'))
684
+ continue;
685
+ const testDef = line.match(/^def\s+(test_[A-Za-z0-9_]+)\s*\(/);
686
+ if (testDef) {
687
+ flush();
688
+ ensureCase(titleFromPythonName(testDef[1]));
689
+ continue;
690
+ }
691
+ const active = ensureCase();
692
+ const gotoMatch = line.match(/\.(?:get|goto)\(\s*([ruRU]?)(["'])(.*?)\2\s*\)/);
693
+ if (gotoMatch) {
694
+ const url = gotoMatch[3];
695
+ active.steps.push({ action: `Navigate to ${url}`, expected: 'Page loads successfully' });
696
+ mergeTestDataInline(active, { startUrl: url });
697
+ continue;
698
+ }
699
+ const sendKeysMatch = line.match(/find_element\s*\(([^)]{0,240})\)\.send_keys\(\s*([ruRU]?)(["'])(.*?)\3\s*\)/);
700
+ if (sendKeysMatch) {
701
+ const locatorExpr = sendKeysMatch[1];
702
+ const value = sendKeysMatch[4];
703
+ const locatorHint = tryConvertXPathToSemantic(locatorExpr.match(/["']([^"']+)["']\s*$/)?.[1] ?? locatorExpr);
704
+ const fieldKey = pythonLocatorFieldKey(locatorExpr, fieldIdx++);
705
+ active.steps.push({
706
+ action: `Enter value into ${fieldKey}`,
707
+ data: value,
708
+ expected: `${fieldKey} accepts the value`,
709
+ ...(locatorHint ? { comment: `locatorHint: ${locatorHint}` } : {}),
710
+ });
711
+ mergeTestDataInline(active, { [fieldKey]: value });
712
+ continue;
713
+ }
714
+ const clickMatch = line.match(/find_element\s*\(([^)]{0,240})\)\.click\(\s*\)/);
715
+ if (clickMatch) {
716
+ const locatorExpr = clickMatch[1];
717
+ const locator = locatorExpr.match(/["']([^"']+)["']\s*$/)?.[1] ?? locatorExpr;
718
+ const locatorHint = tryConvertXPathToSemantic(locator);
719
+ const label = locator.match(/text\(\)\s*=\s*["']([^"']+)["']/)?.[1]
720
+ ?? locator.match(/@(?:aria-label|id|name)\s*=\s*["']([^"']+)["']/)?.[1]
721
+ ?? `element ${active.steps.length + 1}`;
722
+ active.steps.push({
723
+ action: `Click ${label}`,
724
+ expected: 'Action completes successfully',
725
+ ...(locatorHint ? { comment: `locatorHint: ${locatorHint}` } : {}),
726
+ });
727
+ continue;
728
+ }
729
+ const assertMatch = line.match(/^assert\s+(.+)/);
730
+ if (assertMatch) {
731
+ active.steps.push({ action: `Verify ${assertMatch[1].slice(0, 180)}`, expected: 'Assertion passes' });
732
+ }
733
+ }
734
+ flush();
735
+ return { cases: cases.map(c => sanitizeParsedCase(c)).filter((c) => c !== null), errors: [] };
736
+ }
737
+ function parseSpecFile(content, filePath) {
738
+ if (filePath?.toLowerCase().endsWith('.py') || /\bdef\s+test_[A-Za-z0-9_]+\s*\(/.test(content) || /find_element\s*\(|from\s+selenium\b|import\s+pytest\b/.test(content)) {
739
+ return parsePythonSpecFile(content, filePath);
740
+ }
741
+ const cases = [];
742
+ const errors = [];
743
+ const describeStack = [];
744
+ const depthAtDescribe = [];
745
+ let braceDepth = 0;
746
+ // Infer a fallback section from file path (parent folder, stripped of test-dir markers)
747
+ const TEST_DIR_MARKERS = new Set(['tests', 'test', 'e2e', 'cypress', 'spec', 'specs', '__tests__', 'integration', 'unit', 'features']);
748
+ let fileSection;
749
+ if (filePath) {
750
+ const parts = filePath.split('/');
751
+ // Last part is the filename; strip test extension
752
+ const filename = (parts.pop() ?? '').replace(/\.(spec|test|cy)\.[jt]sx?$/, '');
753
+ // Walk backwards to find a meaningful non-marker folder
754
+ const folder = [...parts].reverse().find(p => !TEST_DIR_MARKERS.has(p.toLowerCase()));
755
+ fileSection = folder ?? filename;
756
+ }
757
+ for (const rawLine of content.split('\n')) {
758
+ const trimmed = rawLine.trim();
759
+ // Skip single-line comments
760
+ if (trimmed.startsWith('//') || trimmed.startsWith('*')) {
761
+ continue;
762
+ }
763
+ // Count net braces in line, skipping string contents
764
+ let lineOpen = 0, lineClose = 0;
765
+ let inStr = false, strChar = '';
766
+ for (const ch of rawLine) {
767
+ if (inStr) {
768
+ if (ch === strChar)
769
+ inStr = false;
770
+ }
771
+ else if (ch === '"' || ch === "'" || ch === '`') {
772
+ inStr = true;
773
+ strChar = ch;
774
+ }
775
+ else if (ch === '{')
776
+ lineOpen++;
777
+ else if (ch === '}')
778
+ lineClose++;
779
+ }
780
+ braceDepth += lineOpen - lineClose;
781
+ // Pop finished describe blocks
782
+ while (depthAtDescribe.length > 0 && braceDepth < depthAtDescribe[depthAtDescribe.length - 1]) {
783
+ depthAtDescribe.pop();
784
+ describeStack.pop();
785
+ }
786
+ // describe / context / suite (including .only, .skip, .each)
787
+ const describeMatch = trimmed.match(/^(?:describe|context|suite)(?:\.only|\.skip|\.each\([^)]*\))?\s*\(\s*(["'`])(.*?)\1/);
788
+ if (describeMatch) {
789
+ describeStack.push(describeMatch[2]);
790
+ depthAtDescribe.push(braceDepth);
791
+ continue;
792
+ }
793
+ // it / test / specify (including .only, .skip, .each)
794
+ const testMatch = trimmed.match(/^(?:it|test|specify)(?:\.only|\.skip|\.each\([^)]*\))?\s*\(\s*(["'`])(.*?)\1/);
795
+ if (!testMatch)
796
+ continue;
797
+ const title = testMatch[2];
798
+ const section = describeStack.length > 0
799
+ ? describeStack[describeStack.length - 1]
800
+ : fileSection;
801
+ const outerTag = describeStack.length > 1 ? describeStack[0] : undefined;
802
+ cases.push({
803
+ title,
804
+ kind: 'E2E',
805
+ priority: 'MEDIUM',
806
+ section: section ?? undefined,
807
+ tags: outerTag ? [outerTag] : undefined,
808
+ steps: [{ action: `Execute: ${title}`, expected: 'Test passes without errors' }],
809
+ expectedResult: 'Test passes without errors',
810
+ });
811
+ }
812
+ return { cases, errors };
813
+ }
814
+ function parseGherkin(content) {
815
+ const cases = [];
816
+ const backgroundSteps = [];
817
+ let pendingTags = [];
818
+ let current = null;
819
+ let mode = 'none';
820
+ let inDocString = false;
821
+ let docStringChar = '';
822
+ let docStringContent = [];
823
+ const flush = () => {
824
+ if (current)
825
+ cases.push(...expandScenario(current, backgroundSteps));
826
+ current = null;
827
+ inDocString = false;
828
+ };
829
+ for (const rawLine of content.split('\n')) {
830
+ const line = rawLine.trim();
831
+ if (!line && !inDocString)
832
+ continue;
833
+ if (inDocString) {
834
+ if (line === docStringChar) {
835
+ inDocString = false;
836
+ if (current && current.stepLines.length > 0) {
837
+ const lastStep = current.stepLines[current.stepLines.length - 1];
838
+ lastStep.data = docStringContent.join('\n');
839
+ }
840
+ }
841
+ else {
842
+ docStringContent.push(rawLine); // keep original indent
843
+ }
844
+ if (current)
845
+ current.raw.push(rawLine);
846
+ continue;
847
+ }
848
+ if (line.startsWith('"""') || line.startsWith("'''")) {
849
+ inDocString = true;
850
+ docStringChar = line.slice(0, 3);
851
+ docStringContent = [];
852
+ if (current)
853
+ current.raw.push(rawLine);
854
+ continue;
855
+ }
856
+ if (/^@[\w-]/.test(line)) {
857
+ pendingTags.push(...(line.match(/@[\w-]+/g) ?? []));
858
+ continue;
859
+ }
860
+ if (/^Background:/.test(line)) {
861
+ flush();
862
+ mode = 'background';
863
+ pendingTags = [];
864
+ continue;
865
+ }
866
+ const scenMatch = line.match(/^(Scenario Outline|Scenario):\s*(.*)/);
867
+ if (scenMatch) {
868
+ flush();
869
+ current = {
870
+ title: scenMatch[2].trim(),
871
+ tags: pendingTags,
872
+ isOutline: scenMatch[1] === 'Scenario Outline',
873
+ stepLines: [],
874
+ exampleLines: [],
875
+ raw: [rawLine],
876
+ };
877
+ pendingTags = [];
878
+ mode = 'scenario';
879
+ continue;
880
+ }
881
+ if (/^Examples:/.test(line)) {
882
+ if (current) {
883
+ mode = 'examples';
884
+ current.raw.push(rawLine);
885
+ }
886
+ continue;
887
+ }
888
+ if (mode === 'background' && /^(Given|When|Then|And|But|\*)\s+/.test(line)) {
889
+ backgroundSteps.push(line);
890
+ }
891
+ else if (mode === 'scenario' && current && /^(Given|When|Then|And|But|\*)\s+/.test(line)) {
892
+ current.stepLines.push({ text: line });
893
+ current.raw.push(rawLine);
894
+ }
895
+ else if (mode === 'examples' && current && line.startsWith('|')) {
896
+ current.exampleLines.push(line);
897
+ current.raw.push(rawLine);
898
+ }
899
+ else if (mode === 'scenario' && current && line.startsWith('|')) {
900
+ // Step data table
901
+ if (current.stepLines.length > 0) {
902
+ const lastStep = current.stepLines[current.stepLines.length - 1];
903
+ lastStep.data = lastStep.data ? lastStep.data + '\n' + line : line;
904
+ }
905
+ current.raw.push(rawLine);
906
+ }
907
+ else if (current) {
908
+ current.raw.push(rawLine);
909
+ }
910
+ }
911
+ flush();
912
+ return { cases, errors: [] };
913
+ }
914
+ function buildGherkinSteps(stepLines) {
915
+ const steps = [];
916
+ let expectedResult = '';
917
+ let inThen = false;
918
+ for (const step of stepLines) {
919
+ if (/^Then\s/.test(step.text))
920
+ inThen = true;
921
+ else if (/^(Given|When)\s/.test(step.text))
922
+ inThen = false;
923
+ if (inThen) {
924
+ expectedResult = step.text.replace(/^(?:Then|And|But|\*)\s+/, '');
925
+ steps.push({ action: step.text, data: step.data, expected: expectedResult });
926
+ }
927
+ else {
928
+ steps.push({ action: step.text, data: step.data, expected: '' });
929
+ }
930
+ }
931
+ return { steps, expectedResult };
932
+ }
933
+ function expandScenario(sc, backgroundSteps) {
934
+ if (sc.stepLines.length === 0)
935
+ return [];
936
+ const gherkin = sc.raw.join('\n').trim();
937
+ const preconditions = backgroundSteps.length
938
+ ? backgroundSteps.map((s) => s.replace(/^(?:Given|And|But|\*)\s+/, '')).join('; ')
939
+ : undefined;
940
+ const make = (title, stepLines, testDataInline) => {
941
+ const { steps, expectedResult } = buildGherkinSteps(stepLines);
942
+ return {
943
+ title,
944
+ kind: 'POSITIVE',
945
+ priority: 'MEDIUM',
946
+ preconditions,
947
+ steps,
948
+ expectedResult: expectedResult || title,
949
+ gherkin,
950
+ tags: sc.tags,
951
+ ...(testDataInline && Object.keys(testDataInline).length > 0 ? { testDataInline } : {}),
952
+ };
953
+ };
954
+ // Expand Scenario Outline: one concrete case per Examples row
955
+ if (sc.isOutline && sc.exampleLines.length >= 2) {
956
+ const parseRow = (l) => l.split('|').slice(1, -1).map((c) => c.trim());
957
+ const header = parseRow(sc.exampleLines[0]);
958
+ return sc.exampleLines.slice(1).map((rowLine, i) => {
959
+ const row = parseRow(rowLine);
960
+ const substitute = (s) => header.reduce((acc, h, j) => acc.split(`<${h}>`).join(row[j] ?? ''), s);
961
+ const subSteps = sc.stepLines.map((s) => ({
962
+ text: substitute(s.text),
963
+ data: s.data ? substitute(s.data) : undefined,
964
+ }));
965
+ const rawExampleData = Object.fromEntries(header.map((h, j) => [h, row[j] ?? '']).filter(([, v]) => String(v).trim()));
966
+ const exampleData = parseImportedTestData(rawExampleData) ?? {};
967
+ return make(`${substitute(sc.title)} [example ${i + 1}]`, subSteps, exampleData);
968
+ });
969
+ }
970
+ return [make(sc.title, sc.stepLines)];
971
+ }
972
+ // ─── Tabular (Excel / CSV as markdown tables) ────────────────────────────────
973
+ /**
974
+ * Extract headers and up to `maxSamples` data rows from the first sheet
975
+ * of a markdown-table artifact. Used by the /infer-mapping route.
976
+ */
977
+ function extractTabularHeaders(content, maxSamples = 3) {
978
+ // Use first sheet section if multi-sheet
979
+ const firstSection = content.split(/^### Sheet:.*$/m)[1] ?? content;
980
+ const lines = firstSection.split('\n').filter(Boolean);
981
+ const headerIdx = lines.findIndex((l) => l.startsWith('|'));
982
+ if (headerIdx < 0)
983
+ return { headers: [], sampleRows: [] };
984
+ const rawHeaders = lines[headerIdx]
985
+ .split('|').slice(1, -1).map((h) => h.trim()).filter(Boolean);
986
+ const parseCells = (row) => row.split('|').map((c) => c.trim()).filter((_, i, a) => i > 0 && i < a.length - 1);
987
+ const sampleRows = lines
988
+ .slice(headerIdx + 2)
989
+ .filter((l) => l.startsWith('|') && !l.match(/^[\s|:-]+$/))
990
+ .slice(0, maxSamples)
991
+ .map(parseCells);
992
+ return { headers: rawHeaders, sampleRows };
993
+ }
994
+ function parseTabular(content, mapping, sourcePlatform) {
995
+ // A multi-sheet workbook produces one "### Sheet:" section per sheet,
996
+ // each with its own header row — parse each section independently.
997
+ const parts = content.split(/^### Sheet:\s*(.*)$/m);
998
+ const cases = [];
999
+ const errors = [];
1000
+ // parts[0] is everything before the first "### Sheet:"
1001
+ // If there are no sheets, parts = [content]
1002
+ if (parts.length === 1) {
1003
+ const res = parseTabularSection(parts[0], mapping, undefined, sourcePlatform);
1004
+ return { cases: res.cases, errors: res.errors };
1005
+ }
1006
+ for (let i = 1; i < parts.length; i += 2) {
1007
+ const sheetName = parts[i].trim();
1008
+ const sheetContent = parts[i + 1] ?? '';
1009
+ const res = parseTabularSection(sheetContent, mapping, sheetName, sourcePlatform);
1010
+ cases.push(...res.cases);
1011
+ errors.push(...res.errors);
1012
+ }
1013
+ return { cases, errors };
1014
+ }
1015
+ // StackLens row-per-step format: TC_Id # | TC Description | Test Data | Steps# | Test Steps | Expected Result …
1016
+ function parseStackLens(rawHeaders, dataRows, sourcePlatform) {
1017
+ const parseCells = (row) => row.split('|').map(c => c.trim().replace(/\\\|/g, '|')).filter((_, i, a) => i > 0 && i < a.length - 1);
1018
+ const h = rawHeaders.map(x => x.toLowerCase().replace(/[\s_#]+/g, ''));
1019
+ const idxOf = (...keys) => {
1020
+ const i = h.findIndex(x => keys.some(k => x.includes(k)));
1021
+ return i >= 0 ? i : -1;
1022
+ };
1023
+ const tcIdCol = idxOf('tcid');
1024
+ const descCol = idxOf('tcdescription', 'description');
1025
+ const priorityCol = idxOf('priority', 'severity');
1026
+ const kindCol = idxOf('type', 'kind', 'category');
1027
+ const preconditionsCol = idxOf('preconditions', 'prerequisites');
1028
+ const testDataCol = idxOf('testdata');
1029
+ const _stepsNumCol = idxOf('steps#', 'stepsno', 'stepno');
1030
+ const stepsCol = idxOf('teststep', 'steps');
1031
+ const expectedCol = idxOf('expectedresult', 'expected');
1032
+ const actualResultCol = idxOf('actualresult', 'actual');
1033
+ const statusCol = idxOf('status', 'teststatus', 'runstatus', 'passfail');
1034
+ const commentsCol = idxOf('comment', 'notes', 'remark', 'observation');
1035
+ const screenshotCol = idxOf('screenshot', 'evidence', 'attachment');
1036
+ const tagsCol = idxOf('tags', 'label', 'markers');
1037
+ const sectionCol = idxOf('module', 'suite', 'section', 'folder', 'component');
1038
+ const cases = [];
1039
+ let current = null;
1040
+ for (const row of dataRows) {
1041
+ const cells = parseCells(row);
1042
+ const tcId = tcIdCol >= 0 ? (cells[tcIdCol] ?? '') : '';
1043
+ const desc = descCol >= 0 ? (cells[descCol] ?? '') : '';
1044
+ const step = stepsCol >= 0 ? (cells[stepsCol] ?? '') : '';
1045
+ const expected = expectedCol >= 0 ? (cells[expectedCol] ?? '') : '';
1046
+ const testData = testDataCol >= 0 ? (cells[testDataCol] ?? '') : '';
1047
+ const actualResult = actualResultCol >= 0 ? (cells[actualResultCol] ?? '') : '';
1048
+ const status = statusCol >= 0 ? (cells[statusCol] ?? '') : '';
1049
+ const comment = commentsCol >= 0 ? (cells[commentsCol] ?? '') : '';
1050
+ const screenshotUrl = screenshotCol >= 0 ? (cells[screenshotCol] ?? '') : '';
1051
+ const tagsRaw = tagsCol >= 0 ? (cells[tagsCol] ?? '') : '';
1052
+ if (tcId) {
1053
+ current = {
1054
+ ref: normalizeRef(tcId, sourcePlatform),
1055
+ title: desc || tcId,
1056
+ kind: kindCol >= 0 ? normalizeKind(cells[kindCol] ?? '') : 'POSITIVE',
1057
+ priority: priorityCol >= 0 ? ((0, core_1.normalizePriority)(cells[priorityCol] ?? '') ?? 'MEDIUM') : 'MEDIUM',
1058
+ preconditions: preconditionsCol >= 0 ? (cells[preconditionsCol] || undefined) : (testData || undefined),
1059
+ steps: [],
1060
+ expectedResult: expected || desc || tcId,
1061
+ actualResult: actualResult || undefined,
1062
+ status: status || undefined,
1063
+ comment: comment || undefined,
1064
+ screenshotUrl: screenshotUrl || undefined,
1065
+ tags: tagsRaw ? tagsRaw.split(/[,;|]/).map(t => t.trim()).filter(Boolean) : undefined,
1066
+ section: sectionCol >= 0 ? (cells[sectionCol] || undefined) : undefined,
1067
+ };
1068
+ mergeTestDataInline(current, testData);
1069
+ cases.push(current);
1070
+ }
1071
+ if (current && step) {
1072
+ mergeTestDataInline(current, testData);
1073
+ current.steps.push({
1074
+ action: step,
1075
+ data: testData || undefined,
1076
+ expected: expected || '',
1077
+ actualResult: actualResult || undefined,
1078
+ comment: comment || undefined,
1079
+ screenshotUrl: screenshotUrl || undefined,
1080
+ });
1081
+ }
1082
+ }
1083
+ return {
1084
+ cases: cases.filter(c => c.title).map(c => ({
1085
+ ...c,
1086
+ steps: c.steps.length > 0 ? c.steps : [{ action: 'Execute test case', expected: c.expectedResult }],
1087
+ expectedResult: c.expectedResult || c.title,
1088
+ })),
1089
+ errors: [],
1090
+ };
1091
+ }
1092
+ function parseTabularSection(content, mapping, defaultSection, sourcePlatform) {
1093
+ const cases = [];
1094
+ const errors = [];
1095
+ const lines = content.split('\n').filter(Boolean);
1096
+ // Find header row
1097
+ const headerIdx = lines.findIndex((l) => l.startsWith('|'));
1098
+ if (headerIdx < 0)
1099
+ return { cases, errors };
1100
+ const rawHeaders = lines[headerIdx]
1101
+ .split('|').slice(1, -1).map((h) => h.trim());
1102
+ // Detect StackLens row-per-step format (TC_Id # column present)
1103
+ if (rawHeaders.some(h => h.toLowerCase().replace(/[\s_#]+/g, '') === 'tcid')) {
1104
+ const dataRows = lines.slice(headerIdx + 2).filter((l) => l.startsWith('|') && !l.match(/^[\s|:-]+$/));
1105
+ return parseStackLens(rawHeaders, dataRows, sourcePlatform);
1106
+ }
1107
+ const dataRows = lines.slice(headerIdx + 2).filter((l) => l.startsWith('|') && !l.match(/^[\s|:-]+$/));
1108
+ // Resolve column indices: use provided mapping when available, fall back to heuristic
1109
+ const resolved = mapping ?? _heuristicMapping(rawHeaders);
1110
+ const col = (field) => resolved[field] !== undefined ? resolved[field] : -1;
1111
+ const titleCol = col('title');
1112
+ const refCol = col('ref');
1113
+ const stepsCol = col('steps');
1114
+ const expectedCol = col('expected');
1115
+ const priorityCol = col('priority');
1116
+ const kindCol = col('kind');
1117
+ const preconCol = col('preconditions');
1118
+ const dataCol = col('data');
1119
+ const tagsCol = col('tags');
1120
+ const sectionCol = col('section');
1121
+ const caseStartCol = col('caseStart');
1122
+ const actualResultCol = col('actualResult');
1123
+ const commentCol = col('comment');
1124
+ const stepIdCol = col('stepId');
1125
+ const screenshotUrlCol = col('screenshotUrl');
1126
+ const statusCol = col('status');
1127
+ if (titleCol < 0 && refCol < 0) {
1128
+ errors.push(`Missing 'Title' or 'Ref' column mapping (Sheet: ${defaultSection ?? 'Default'})`);
1129
+ return { cases, errors };
1130
+ }
1131
+ const parseCells = (row) => row.split('|').map((c) => c.trim().replace(/\\\|/g, '|')).filter((_, i, a) => i > 0 && i < a.length - 1);
1132
+ let current = null;
1133
+ let rowCount = 0;
1134
+ for (const row of dataRows) {
1135
+ rowCount++;
1136
+ const cells = parseCells(row);
1137
+ const title = titleCol >= 0 ? cells[titleCol] ?? '' : '';
1138
+ const ref = refCol >= 0 ? cells[refCol] ?? '' : '';
1139
+ const stepsRaw = stepsCol >= 0 ? cells[stepsCol] ?? '' : '';
1140
+ const expected = expectedCol >= 0 ? cells[expectedCol] ?? '' : '';
1141
+ const data = dataCol >= 0 ? cells[dataCol] ?? '' : '';
1142
+ const tagsRaw = tagsCol >= 0 ? cells[tagsCol] ?? '' : '';
1143
+ const sectionRaw = sectionCol >= 0 ? cells[sectionCol] ?? '' : '';
1144
+ const actualResultRaw = actualResultCol >= 0 ? cells[actualResultCol] ?? '' : '';
1145
+ const commentRaw = commentCol >= 0 ? cells[commentCol] ?? '' : '';
1146
+ const stepIdRaw = stepIdCol >= 0 ? cells[stepIdCol] ?? '' : '';
1147
+ const screenshotUrlRaw = screenshotUrlCol >= 0 ? cells[screenshotUrlCol] ?? '' : '';
1148
+ let isNewCase = false;
1149
+ if (caseStartCol >= 0) {
1150
+ isNewCase = !!(cells[caseStartCol]?.trim());
1151
+ }
1152
+ else {
1153
+ // Require at least some content alongside the ref — reject ID-only rows
1154
+ isNewCase = !!(title || (ref && (stepsRaw || expected || data)));
1155
+ }
1156
+ if (isNewCase || (!current && (title || (ref && (stepsRaw || expected || data))))) {
1157
+ const tags = tagsRaw ? tagsRaw.split(/[,;|]/).map((t) => t.trim()).filter(Boolean) : undefined;
1158
+ const section = sectionRaw || defaultSection;
1159
+ current = {
1160
+ ref: ref ? normalizeRef(ref, sourcePlatform) : undefined,
1161
+ title: title || ref,
1162
+ kind: kindCol >= 0 ? normalizeKind(cells[kindCol] ?? '') : 'POSITIVE',
1163
+ priority: priorityCol >= 0 ? ((0, core_1.normalizePriority)(cells[priorityCol] ?? '') ?? 'MEDIUM') : 'MEDIUM',
1164
+ preconditions: preconCol >= 0 ? (cells[preconCol] || undefined) : undefined,
1165
+ steps: [],
1166
+ expectedResult: expected || title || ref,
1167
+ actualResult: actualResultRaw || undefined,
1168
+ comment: commentRaw || undefined,
1169
+ screenshotUrl: screenshotUrlRaw || undefined,
1170
+ status: statusCol >= 0 ? (cells[statusCol] ?? undefined) : undefined,
1171
+ tags,
1172
+ section,
1173
+ };
1174
+ mergeTestDataInline(current, data);
1175
+ cases.push(current);
1176
+ }
1177
+ if (!current) {
1178
+ if (stepsRaw || expected) {
1179
+ errors.push(`Row ${rowCount} (Sheet: ${defaultSection ?? 'Default'}): Step data ignored because it lacks a title/ref to start a test case.`);
1180
+ }
1181
+ continue;
1182
+ }
1183
+ if (stepsRaw) {
1184
+ mergeTestDataInline(current, data);
1185
+ // Split on newlines, semicolons, or "1. " / "1) " numbering — the marker
1186
+ // requires trailing whitespace so decimals like "1.5" stay intact.
1187
+ const stepList = stepsRaw.split(/\n|;|\s*\d+[.)]\s+/).map((s) => s.trim()).filter(Boolean);
1188
+ for (let si = 0; si < stepList.length; si++) {
1189
+ const s = stepList[si];
1190
+ const derivedStepId = stepIdRaw || (stepList.length > 1 ? String(si + 1) : undefined);
1191
+ current.steps.push({
1192
+ stepId: derivedStepId,
1193
+ action: s,
1194
+ data: data || undefined,
1195
+ expected: expected || '',
1196
+ actualResult: actualResultRaw || undefined,
1197
+ comment: commentRaw || undefined,
1198
+ screenshotUrl: screenshotUrlRaw || undefined,
1199
+ });
1200
+ }
1201
+ }
1202
+ else if (expected && !current.expectedResult) {
1203
+ current.expectedResult = expected;
1204
+ }
1205
+ else if (expected && current.steps.length > 0) {
1206
+ current.steps[current.steps.length - 1].expected = expected;
1207
+ }
1208
+ }
1209
+ const finalCases = cases
1210
+ .filter((c) => c.title)
1211
+ .map((c) => ({
1212
+ ...c,
1213
+ steps: c.steps.length > 0 ? c.steps : [{ action: 'Execute test case', expected: c.expectedResult }],
1214
+ expectedResult: c.expectedResult || c.title,
1215
+ }));
1216
+ return { cases: finalCases, errors };
1217
+ }
1218
+ // ─── AI extraction ───────────────────────────────────────────────────────────
1219
+ const AI_TARGET_CHUNK_SIZE = 10000;
1220
+ const AI_MAX_CHUNK_SIZE = 15000;
1221
+ const AI_MAX_CHUNKS = 15;
1222
+ function chunkContent(content) {
1223
+ if (content.length <= 12000)
1224
+ return [content];
1225
+ // Try section-based chunking on headings (#, ##, ###, or 1. Section, or Section 1)
1226
+ const lines = content.split(/\r?\n/);
1227
+ const sections = [];
1228
+ let curLines = [];
1229
+ let curHeading = 'Introduction';
1230
+ for (const line of lines) {
1231
+ const isHeading = /^#{1,4}\s+[A-Za-z0-9]/.test(line) ||
1232
+ /^(?:Section\s+\d+|\d+\.\s+[A-Z])/.test(line) ||
1233
+ /^[A-Z][A-Za-z0-9 &/_().:-]{3,60}:$/m.test(line);
1234
+ if (isHeading && curLines.length > 0) {
1235
+ sections.push({ heading: curHeading, text: curLines.join('\n') });
1236
+ curLines = [line];
1237
+ curHeading = line.replace(/^#{1,4}\s+/, '').trim().slice(0, 80);
1238
+ }
1239
+ else {
1240
+ curLines.push(line);
1241
+ }
1242
+ }
1243
+ if (curLines.length > 0) {
1244
+ sections.push({ heading: curHeading, text: curLines.join('\n') });
1245
+ }
1246
+ // Combine sections into chunks targetting 8k-12k characters
1247
+ const chunks = [];
1248
+ let currentChunk = '';
1249
+ for (const sec of sections) {
1250
+ // If a single section is itself massive (> AI_MAX_CHUNK_SIZE), split it by paragraphs or table blocks
1251
+ if (sec.text.length > AI_MAX_CHUNK_SIZE) {
1252
+ if (currentChunk.trim()) {
1253
+ chunks.push(currentChunk.trim());
1254
+ currentChunk = '';
1255
+ }
1256
+ let subPos = 0;
1257
+ while (subPos < sec.text.length && chunks.length < AI_MAX_CHUNKS) {
1258
+ let subEnd = Math.min(subPos + AI_TARGET_CHUNK_SIZE, sec.text.length);
1259
+ if (subEnd < sec.text.length) {
1260
+ const nl = sec.text.lastIndexOf('\n\n', subEnd);
1261
+ if (nl > subPos + AI_TARGET_CHUNK_SIZE / 2)
1262
+ subEnd = nl;
1263
+ }
1264
+ chunks.push(sec.text.slice(subPos, subEnd).trim());
1265
+ subPos = subEnd;
1266
+ }
1267
+ continue;
1268
+ }
1269
+ if (!currentChunk) {
1270
+ currentChunk = sec.text;
1271
+ }
1272
+ else if (currentChunk.length + sec.text.length <= AI_MAX_CHUNK_SIZE) {
1273
+ currentChunk += '\n\n' + sec.text;
1274
+ }
1275
+ else {
1276
+ chunks.push(currentChunk.trim());
1277
+ currentChunk = sec.text;
1278
+ if (chunks.length >= AI_MAX_CHUNKS - 1)
1279
+ break;
1280
+ }
1281
+ }
1282
+ if (currentChunk.trim() && chunks.length < AI_MAX_CHUNKS) {
1283
+ chunks.push(currentChunk.trim());
1284
+ }
1285
+ // Fallback: If section detection produced fewer than 2 chunks on a large doc, fall back to sliding window
1286
+ if (chunks.length <= 1 && content.length > 16000) {
1287
+ chunks.length = 0;
1288
+ let pos = 0;
1289
+ while (pos < content.length && chunks.length < AI_MAX_CHUNKS) {
1290
+ let end = Math.min(pos + AI_TARGET_CHUNK_SIZE, content.length);
1291
+ if (end < content.length) {
1292
+ const nl = content.lastIndexOf('\n', end);
1293
+ if (nl > pos + AI_TARGET_CHUNK_SIZE / 2)
1294
+ end = nl;
1295
+ }
1296
+ chunks.push(content.slice(pos, end).trim());
1297
+ pos = end;
1298
+ }
1299
+ }
1300
+ return chunks.filter(c => c.length > 50);
1301
+ }
1302
+ const AI_SCENARIO_SCHEMA = {
1303
+ type: 'object',
1304
+ properties: {
1305
+ cases: {
1306
+ type: 'array',
1307
+ items: {
1308
+ type: 'object',
1309
+ properties: {
1310
+ title: { type: 'string', description: 'Verb-first descriptive title (e.g. "Verify payment total after coupon"). Max 120 chars.', maxLength: 120 },
1311
+ kind: { type: 'string', enum: ['POSITIVE', 'NEGATIVE', 'EDGE'] },
1312
+ priority: { type: 'string', enum: ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] },
1313
+ preconditions: { type: ['string', 'null'] },
1314
+ steps: {
1315
+ type: 'array',
1316
+ description: 'Actionable sequential steps (4 to 8 steps). Include setup/role, specific field inputs/values, action trigger, and precise expected assertion.',
1317
+ minItems: 4,
1318
+ items: {
1319
+ type: 'object',
1320
+ properties: {
1321
+ action: { type: 'string', description: 'Concrete tester action with field names, values, or UI buttons.' },
1322
+ expected: { type: 'string', description: 'Observable system reaction, state change, or verification.' },
1323
+ },
1324
+ required: ['action', 'expected'],
1325
+ },
1326
+ },
1327
+ expectedResult: { type: 'string' },
1328
+ tags: { type: 'array', items: { type: 'string' } },
1329
+ traceTo: { type: 'string', description: 'Source requirement or business-rule ID such as FR006, NFR002, or BR-VSA-ACCEPTANCE when provided.' },
1330
+ caseRefs: {
1331
+ type: 'array',
1332
+ items: {
1333
+ type: 'object',
1334
+ properties: {
1335
+ type: { type: 'string' },
1336
+ key: { type: 'string' },
1337
+ sourceSection: { type: 'string' },
1338
+ sourceExcerpt: { type: 'string' },
1339
+ },
1340
+ },
1341
+ },
1342
+ },
1343
+ required: ['title', 'kind', 'priority', 'steps'],
1344
+ },
1345
+ },
1346
+ },
1347
+ required: ['cases'],
1348
+ };
1349
+ const AI_SCENARIO_SYSTEM = `You are a senior QA analyst generating test cases for a specific scenario.
1350
+ Rules:
1351
+ - Generate EXACTLY the requested number of distinct, varied test cases.
1352
+ - "kind" MUST be one of exactly three values: POSITIVE, NEGATIVE, or EDGE. No other values are allowed.
1353
+ - POSITIVE: happy path, expected successful behavior
1354
+ - NEGATIVE: error conditions, invalid input, rejection, failure handling
1355
+ - EDGE: boundary values, concurrent access, timeout, partial failure, rollback
1356
+ - Case mix: when generating 3 or more cases, include at least 1 POSITIVE, 1 NEGATIVE, and 1 EDGE. For 1-2 cases, choose the highest-risk kind(s) for the source requirement.
1357
+ - "priority": CRITICAL/HIGH/MEDIUM/LOW
1358
+ - Each test case MUST have the number of steps needed to execute the behavior clearly, usually 3–10. Do not pad to a fixed count.
1359
+ Steps SHOULD cover these phases when relevant: (1) prerequisite/navigation setup, (2) initial state verification, (3) data entry or configuration input, (4) primary action trigger, (5+) one step per distinct acceptance criterion or verifiable outcome.
1360
+ - Every step must have a concrete action AND expected result — never leave either blank.
1361
+ - Do NOT collapse multiple behaviors or outcomes into one step. Each distinct verifiable condition = its own step.
1362
+ - For NEGATIVE test cases: add steps for attempting the invalid action, observing the specific error/rejection message, and verifying the system recovers to a valid state.
1363
+ - Do NOT duplicate test cases — each must test a distinct behavior.
1364
+ - If the scenario includes Requirement, Source Section, Category, or Source Excerpt metadata, preserve it in traceTo/caseRefs and use it to keep the case grounded.`;
1365
+ function parseScenarioCount(ctx) {
1366
+ const m = ctx.match(/\(generate\s+(\d+)\s+distinct test cases\)/i)
1367
+ ?? ctx.match(/(?:suggested|generate|count)\D{0,20}(\d{1,2})\s+test/i);
1368
+ return m ? Math.max(1, Math.min(20, parseInt(m[1], 10))) : 3;
1369
+ }
1370
+ function parseScenarioContextMeta(scenarioCtx) {
1371
+ const firstLine = scenarioCtx.split(/\r?\n/)[0] ?? '';
1372
+ const title = firstLine.replace(/\s*\(generate\s+\d+\s+distinct test cases\)\s*/i, '').split(':')[0].trim();
1373
+ const field = (name) => {
1374
+ const re = new RegExp(`^${name}:\\s*(.+)$`, 'im');
1375
+ return scenarioCtx.match(re)?.[1]?.trim();
1376
+ };
1377
+ const traceTo = field('Requirement') ?? field('Trace') ?? field('Rule');
1378
+ const sourceSection = field('Source Section');
1379
+ const sourceExcerpt = field('Source Excerpt');
1380
+ const category = field('Category');
1381
+ const caseRefs = traceTo ? [{
1382
+ type: traceTo.startsWith('BR-') ? 'BRD_BUSINESS_RULE' : 'BRD_REQUIREMENT',
1383
+ key: traceTo,
1384
+ ...(sourceSection ? { sourceSection } : {}),
1385
+ ...(sourceExcerpt ? { sourceExcerpt } : {}),
1386
+ ...(category ? { category } : {}),
1387
+ }] : undefined;
1388
+ return { title, traceTo, sourceSection, sourceExcerpt, category, caseRefs };
1389
+ }
1390
+ function supplementalScenarioCase(scenarioCtx, index) {
1391
+ const meta = parseScenarioContextMeta(scenarioCtx);
1392
+ const titleBase = meta.title || 'Selected scenario';
1393
+ const kinds = ['POSITIVE', 'NEGATIVE', 'EDGE'];
1394
+ const kind = kinds[index % kinds.length];
1395
+ return {
1396
+ traceTo: meta.traceTo,
1397
+ caseRefs: meta.caseRefs,
1398
+ title: `${titleBase} - ${kind.toLowerCase()} path ${index + 1}`,
1399
+ kind,
1400
+ priority: index === 0 ? 'HIGH' : 'MEDIUM',
1401
+ preconditions: `Scenario context: ${scenarioCtx.slice(0, 240)}`,
1402
+ steps: [
1403
+ { action: `Navigate to the workflow for "${titleBase}".`, expected: 'The relevant application workflow is displayed.' },
1404
+ { action: `Exercise the ${kind.toLowerCase()} behavior described by the selected scenario.`, expected: 'The behavior remains aligned with the selected scenario.' },
1405
+ { action: 'Verify the final observable result.', expected: 'The result matches the selected scenario and does not introduce unrelated content.' },
1406
+ ],
1407
+ expectedResult: `The ${kind.toLowerCase()} path for "${titleBase}" is covered.`,
1408
+ tags: ['@scenario-import', `@${kind.toLowerCase()}`],
1409
+ };
1410
+ }
1411
+ function enforceScenarioCaseCount(cases, scenarioCtx, count) {
1412
+ const exact = cases.slice(0, count);
1413
+ const seen = new Set(exact.map((tc) => tc.title.toLowerCase().trim()));
1414
+ let i = 0;
1415
+ while (exact.length < count) {
1416
+ const supplemental = supplementalScenarioCase(scenarioCtx, i);
1417
+ if (!seen.has(supplemental.title.toLowerCase().trim())) {
1418
+ exact.push(supplemental);
1419
+ seen.add(supplemental.title.toLowerCase().trim());
1420
+ }
1421
+ i += 1;
1422
+ }
1423
+ return exact;
1424
+ }
1425
+ /** Stable cache key from a scenario context string — strips count hint, normalises. */
1426
+ function scenarioContextKey(ctx) {
1427
+ return ctx
1428
+ .replace(/\s*\(generate\s+\d+\s+distinct test cases\)\s*/gi, '')
1429
+ .split(':')[0]
1430
+ .toLowerCase()
1431
+ .replace(/[^a-z0-9]/g, '')
1432
+ .trim()
1433
+ .slice(0, 80);
1434
+ }
1435
+ function coerceKind(tc) {
1436
+ if (!['POSITIVE', 'NEGATIVE', 'EDGE'].includes(tc.kind)) {
1437
+ const k = tc.kind.toUpperCase();
1438
+ if (['STATE_TRANSITION', 'E2E', 'INTEGRATION', 'API', 'SMOKE', 'SANITY', 'REGRESSION', 'UAT'].includes(k)) {
1439
+ tc.kind = 'POSITIVE';
1440
+ }
1441
+ else if (['SECURITY', 'ACCESSIBILITY', 'EXPLORATORY', 'USABILITY', 'UNIT', 'MUTATION', 'L10N', 'COMPATIBILITY'].includes(k)) {
1442
+ tc.kind = 'NEGATIVE';
1443
+ }
1444
+ else {
1445
+ tc.kind = 'EDGE';
1446
+ }
1447
+ }
1448
+ }
1449
+ async function extractPerScenario(content, llmCfg, scenarioContext, tenantId) {
1450
+ const effectiveScenarioSystem = await (0, core_1.getAgentPrompt)('test-import-scenario', undefined, tenantId) ?? AI_SCENARIO_SYSTEM;
1451
+ const isContentMinimal = content.trim().length < 300;
1452
+ const docRef = !isContentMinimal
1453
+ ? `\n\nReference document:\n${content.length > 16000 ? content.slice(0, 16000) + '\n… (truncated)' : content}`
1454
+ : '';
1455
+ const allCases = [];
1456
+ const byScenario = {};
1457
+ const seenTitles = new Set();
1458
+ const errors = [];
1459
+ await Promise.all(scenarioContext.map(async (scenarioCtx) => {
1460
+ const cacheKey = scenarioContextKey(scenarioCtx);
1461
+ const count = parseScenarioCount(scenarioCtx);
1462
+ const meta = parseScenarioContextMeta(scenarioCtx);
1463
+ const grounding = [
1464
+ meta.traceTo ? `Requirement: ${meta.traceTo}` : '',
1465
+ meta.sourceSection ? `Source Section: ${meta.sourceSection}` : '',
1466
+ meta.category ? `Category: ${meta.category}` : '',
1467
+ meta.sourceExcerpt ? `Source Excerpt: ${meta.sourceExcerpt}` : '',
1468
+ ].filter(Boolean).join('\n');
1469
+ const user = `Generate exactly ${count} distinct test cases for this scenario. Preserve any source metadata in traceTo/caseRefs:\n${scenarioCtx}${grounding ? `\n\nGrounding metadata:\n${grounding}` : ''}${docRef}`;
1470
+ try {
1471
+ const seed = (0, llm_js_1.deterministicSeed)(`${scenarioCtx}:${count}`);
1472
+ const { text } = await (0, llm_js_1.askTracked)(effectiveScenarioSystem, user, llmCfg, 4096, AI_SCENARIO_SCHEMA, { temperature: 0, seed });
1473
+ const parsed = JSON.parse(text);
1474
+ const scenarioCases = [];
1475
+ for (const raw of (parsed.cases ?? [])) {
1476
+ const tc = sanitizeParsedCase(raw);
1477
+ if (!tc)
1478
+ continue;
1479
+ if (meta.traceTo && !tc.traceTo)
1480
+ tc.traceTo = meta.traceTo;
1481
+ if (meta.caseRefs && !tc.caseRefs?.length)
1482
+ tc.caseRefs = meta.caseRefs;
1483
+ const metaTags = [meta.traceTo, meta.category]
1484
+ .filter(Boolean)
1485
+ .map((t) => `@${String(t).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`);
1486
+ tc.tags = [...new Set([...(tc.tags ?? []), ...metaTags])];
1487
+ coerceKind(tc);
1488
+ scenarioCases.push(tc);
1489
+ }
1490
+ const finalCases = enforceScenarioCaseCount(scenarioCases, scenarioCtx, count);
1491
+ byScenario[cacheKey] = finalCases;
1492
+ for (const tc of finalCases) {
1493
+ const key = tc.title.toLowerCase().trim();
1494
+ if (!seenTitles.has(key)) {
1495
+ seenTitles.add(key);
1496
+ allCases.push(tc);
1497
+ }
1498
+ }
1499
+ }
1500
+ catch (e) {
1501
+ const label = scenarioCtx.replace(/\s*\(generate \d+ distinct test cases\)\s*/, '').split(':')[0].trim();
1502
+ errors.push(`"${label}": ${e?.message ?? String(e)}`);
1503
+ byScenario[cacheKey] = [];
1504
+ }
1505
+ }));
1506
+ return { cases: allCases, byScenario, errors };
1507
+ }
1508
+ /**
1509
+ * Cache-aware scenario test case generator.
1510
+ * Returns cached cases for previously generated scenarios and only calls LLM for new ones.
1511
+ * `existingCaseCache` is the `scenarioCases` map from `cachedScenarios` in DB.
1512
+ * Returns merged cases + `updatedCache` to persist back.
1513
+ */
1514
+ async function parseTestCasesWithCache(content, tenantId, scenarioContext, existingCaseCache) {
1515
+ const llmCfg = await (0, llm_config_js_1.getTaskAgentLLMConfig)(tenantId, 'appBrain').catch(() => null);
1516
+ if (!llmCfg)
1517
+ throw new Error('No LLM provider configured. Please set up an AI model in Workspace → Settings → AI Model before importing.');
1518
+ const cachedCases = [];
1519
+ const uncachedContexts = [];
1520
+ for (const ctx of scenarioContext) {
1521
+ const key = scenarioContextKey(ctx);
1522
+ const hit = existingCaseCache[key];
1523
+ if (hit?.length > 0) {
1524
+ // Re-sanitize cached cases (schema may have changed)
1525
+ const resanitized = hit.map(sanitizeParsedCase).filter((c) => c !== null);
1526
+ resanitized.forEach(coerceKind);
1527
+ cachedCases.push(...resanitized);
1528
+ }
1529
+ else {
1530
+ uncachedContexts.push(ctx);
1531
+ }
1532
+ }
1533
+ if (uncachedContexts.length === 0) {
1534
+ return { cases: cachedCases, errors: [], updatedCache: existingCaseCache };
1535
+ }
1536
+ const { cases: newCases, byScenario, errors } = await extractPerScenario(content, llmCfg, uncachedContexts, tenantId);
1537
+ const updatedCache = { ...existingCaseCache, ...byScenario };
1538
+ return { cases: [...cachedCases, ...newCases], errors, updatedCache };
1539
+ }
1540
+ async function aiExtract(content, tenantId, scenarioContext) {
1541
+ const llmCfg = await (0, llm_config_js_1.getTaskAgentLLMConfig)(tenantId, 'appBrain').catch(() => null);
1542
+ if (!llmCfg)
1543
+ throw new Error('No LLM provider configured. Please set up an AI model in Workspace → Settings → AI Model before importing.');
1544
+ // Per-scenario parallel extraction — each scenario gets its own focused LLM call for accurate counts
1545
+ if (scenarioContext?.length) {
1546
+ const { cases, errors } = await extractPerScenario(content, llmCfg, scenarioContext, tenantId);
1547
+ return { cases, errors };
1548
+ }
1549
+ const AI_EXTRACTION_DEFAULT = `You are a senior QA analyst. Your job is to extract or derive detailed, executable test cases from any document.
1550
+
1551
+ If the document already contains test cases (e.g. a test plan, test case sheet, feature file), extract them directly.
1552
+ If the document contains requirements, user stories, acceptance criteria, or specifications, DERIVE appropriate test cases from them.
1553
+ If the document is a spreadsheet with rows that represent scenarios or behaviors, treat each row as a test case.
1554
+
1555
+ EXHAUSTIVENESS REQUIREMENT — READ CAREFULLY:
1556
+ Extract or derive a test case for EVERY distinct user story, requirement, acceptance criterion, business rule, and workflow mentioned in this document chunk. Do NOT stop early or summarise — cover them all. If a requirement has multiple validation rules, generate one test case per validation rule. If a user story has multiple acceptance criteria, generate one test case per criterion. A 10-requirement chunk should produce at least 10 test cases (typically more: positive + negative per requirement = 20+). Coverage over brevity.
1557
+
1558
+ Rules:
1559
+ - "kind" MUST be one of exactly three values: POSITIVE, NEGATIVE, or EDGE. No other values are allowed.
1560
+ - POSITIVE: happy-path functional test — verifies expected successful behavior (including security controls, performance checks, usability verifications that confirm the system works correctly)
1561
+ - NEGATIVE: error/rejection/invalid-input test — verifies the system rejects bad input, enforces access control, or handles failure states correctly
1562
+ - EDGE: boundary/limit/unusual-condition test — verifies behavior at data limits, concurrent access, timeout scenarios, rollback conditions
1563
+ - "priority" must be one of: CRITICAL, HIGH, MEDIUM, LOW
1564
+ - STEP DEPTH AND SPECIFICITY MANDATE:
1565
+ - NEVER generate 1-step tests or superficial 3-step boilerplate (such as "Log in -> Logged in", "Navigate -> Displayed", "Perform action -> Completed").
1566
+ - NEVER use terminology, role names, system names, or workflows from domains other than the document being processed. Steps must use only the actors, field names, system names, and business rules found in the source document.
1567
+ - Each test case MUST have 4 to 8 sequential, executable steps:
1568
+ 1. Prerequisites / Setup: specific actor role (derived from the document) and initial navigation path.
1569
+ 2. Data Entry & Configuration: specific field names and concrete test data values taken from the source document's business rules, validation tables, and examples.
1570
+ 3. Trigger Action: the specific form submission, button click, or system action described in the source.
1571
+ 4. Immediate State / UI Feedback: field validation outcome, error message, or calculated result as described in the source.
1572
+ 5. System / Database Confirmation: booking reference, order ID, status update, or audit entry as described in the source.
1573
+ - For validation rules & business tables:
1574
+ Wrap each rule into an end-to-end execution workflow (Setup -> Enter boundary/invalid input in the named field -> Trigger -> Verify the specific rejection or error described in the source -> Recover).
1575
+ - FEATURE CATALOG & SUMMARY TABLE MANDATE — READ CAREFULLY:
1576
+ - If the document contains summary or catalog tables defining Functional Features, User Roles, Permissions, or Reports:
1577
+ - You MUST generate dedicated POSITIVE, NEGATIVE, and EDGE test cases for EVERY feature and role listed in those tables!
1578
+ - Do NOT restrict test generation only to sections that contain narrative procedural steps. If a feature is listed in a summary table, derive full executable test cases verifying that feature's business purpose, required inputs, and failure modes using only terminology from the source document.
1579
+ - Extract preconditions stated in the document as the "preconditions" field — do not include them as steps.
1580
+ - If the document covers multiple distinct features, modules, or sections, set the "section" field to the feature/module name on each test case. This enables feature-wise suite organisation. If all cases belong to one feature, omit section.
1581
+ - When the document has explicit requirement IDs (FR001, NFR002) or named business rules, include "traceTo" and "caseRefs" so saved cases can be traced back to the source.
1582
+ - If you cannot derive any meaningful test cases, return an empty array []`;
1583
+ const system = await (0, core_1.getAgentPrompt)('test-import-extraction', undefined, tenantId) ?? AI_EXTRACTION_DEFAULT;
1584
+ const schema = {
1585
+ type: 'object',
1586
+ properties: {
1587
+ cases: {
1588
+ type: 'array',
1589
+ items: {
1590
+ type: 'object',
1591
+ properties: {
1592
+ title: { type: 'string', description: 'Short descriptive title — verb-first, feature-scoped (e.g. "Verify checkout total after coupon"). Max 120 chars.', maxLength: 120 },
1593
+ kind: { type: 'string', enum: ['POSITIVE', 'NEGATIVE', 'EDGE'] },
1594
+ priority: { type: 'string', enum: ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] },
1595
+ preconditions: { type: ['string', 'null'] },
1596
+ steps: {
1597
+ type: 'array',
1598
+ description: 'Actionable sequential test steps (4 to 8 steps). Include operator role setup, specific field inputs with realistic test data values, action trigger, and precise observable expected outcomes. Both action and expected are required and non-empty.',
1599
+ minItems: 4,
1600
+ items: {
1601
+ type: 'object',
1602
+ properties: {
1603
+ action: { type: 'string', description: 'Specific tester action with named fields, UI buttons, or test values.' },
1604
+ expected: { type: 'string', description: 'Concrete observable result — what the tester sees/verifies.' }
1605
+ },
1606
+ required: ['action', 'expected']
1607
+ }
1608
+ },
1609
+ expectedResult: { type: 'string' },
1610
+ tags: { type: 'array', items: { type: 'string' } },
1611
+ section: { type: 'string', description: 'Feature or module name — set when document covers multiple features to enable feature-wise suite grouping.' },
1612
+ traceTo: { type: 'string', description: 'Source requirement or business-rule ID such as FR006, NFR002, or BR-VSA-ACCEPTANCE when identifiable.' },
1613
+ caseRefs: {
1614
+ type: 'array',
1615
+ items: {
1616
+ type: 'object',
1617
+ properties: {
1618
+ type: { type: 'string' },
1619
+ key: { type: 'string' },
1620
+ sourceSection: { type: 'string' },
1621
+ sourceExcerpt: { type: 'string' },
1622
+ },
1623
+ },
1624
+ }
1625
+ },
1626
+ required: ['title', 'kind', 'priority', 'steps']
1627
+ }
1628
+ }
1629
+ },
1630
+ required: ['cases']
1631
+ };
1632
+ const chunks = chunkContent(content);
1633
+ const merged = [];
1634
+ const seenTitles = new Set();
1635
+ const errors = [];
1636
+ let firstError = null;
1637
+ for (let i = 0; i < chunks.length; i++) {
1638
+ const part = chunks.length > 1 ? ` (part ${i + 1} of ${chunks.length})` : '';
1639
+ const user = `Extract or derive test cases from this document${part}:\n\n${chunks[i]}`;
1640
+ try {
1641
+ const seed = (0, llm_js_1.deterministicSeed)(`extract:${i}:${chunks[i].slice(0, 300)}`);
1642
+ const { text } = await (0, llm_js_1.askTracked)(system, user, llmCfg, 16384, schema, { temperature: 0, seed });
1643
+ const parsed = JSON.parse(text);
1644
+ const rawCases = parsed.cases || [];
1645
+ for (const raw of rawCases) {
1646
+ const tc = sanitizeParsedCase(raw);
1647
+ if (!tc)
1648
+ continue;
1649
+ const key = tc.title.toLowerCase();
1650
+ if (seenTitles.has(key))
1651
+ continue;
1652
+ seenTitles.add(key);
1653
+ merged.push(tc);
1654
+ }
1655
+ }
1656
+ catch (err) {
1657
+ const msg = `AI extraction failed (part ${i + 1}): ${err?.message ?? String(err)}`;
1658
+ errors.push(msg);
1659
+ firstError = firstError ?? new Error(msg);
1660
+ }
1661
+ }
1662
+ // Surface the error only when nothing was extracted at all
1663
+ if (merged.length === 0 && firstError)
1664
+ throw firstError;
1665
+ return { cases: merged, errors };
1666
+ }
1667
+ const VALID_KINDS = new Set(['POSITIVE', 'NEGATIVE', 'EDGE', 'STATE_TRANSITION', 'ACCESSIBILITY', 'SECURITY', 'INTEGRATION', 'E2E', 'API', 'EXPLORATORY', 'UAT', 'PERFORMANCE', 'COMPATIBILITY', 'L10N', 'USABILITY', 'LOAD', 'UNIT', 'MUTATION', 'AB_TEST', 'ALPHA_BETA', 'SYSTEM', 'SMOKE', 'REGRESSION', 'SANITY']);
1668
+ function normalizeKind(raw) {
1669
+ const r = raw.trim().toUpperCase().replace(/[-\s]+/g, '_');
1670
+ if (VALID_KINDS.has(r))
1671
+ return r;
1672
+ if (r.includes('SECURITY') || r.includes('SEC'))
1673
+ return 'SECURITY';
1674
+ if (r.includes('ACCESS') || r.includes('A11Y'))
1675
+ return 'ACCESSIBILITY';
1676
+ if (r.includes('PERF'))
1677
+ return 'PERFORMANCE';
1678
+ if (r.includes('NEG') || r.includes('INVALID') || r.includes('ERROR') || r.includes('FAIL'))
1679
+ return 'NEGATIVE';
1680
+ if (r.includes('EDGE') || r.includes('BOUND') || r.includes('LIMIT'))
1681
+ return 'EDGE';
1682
+ if (r.includes('SMOK'))
1683
+ return 'SMOKE';
1684
+ if (r.includes('REGRESS'))
1685
+ return 'REGRESSION';
1686
+ if (r.includes('SANIT'))
1687
+ return 'SANITY';
1688
+ if (r.includes('E2E') || r.includes('END.TO.END') || r.includes('END_TO_END'))
1689
+ return 'E2E';
1690
+ if (r.includes('INTEGRAT'))
1691
+ return 'INTEGRATION';
1692
+ if (r.includes('API') || r.includes('CONTRACT'))
1693
+ return 'API';
1694
+ if (r.includes('UAT') || r.includes('ACCEPTANCE'))
1695
+ return 'UAT';
1696
+ if (r.includes('LOAD') || r.includes('STRESS'))
1697
+ return 'LOAD';
1698
+ if (r.includes('COMPAT'))
1699
+ return 'COMPATIBILITY';
1700
+ if (r.includes('UNIT'))
1701
+ return 'UNIT';
1702
+ // FUNCTIONAL / HAPPY PATH / POSITIVE → POSITIVE
1703
+ return 'POSITIVE';
1704
+ }
1705
+ // ─── Functionize / FZE recording-format parser ───────────────────────────────
1706
+ // Handles the key→value JSON format emitted by Functionize (FZE) and similar
1707
+ // browser test recorders. Supports 50+ key prefixes covering navigation, form
1708
+ // inputs, dropdowns, checkboxes, radio buttons, assertions, custom JavaScript,
1709
+ // context/iframe switches, conditional branching, and ASP.NET ctl* controls.
1710
+ //
1711
+ // JSON repair is applied before parsing because the FZE exporter emits literal
1712
+ // newlines and unescaped double-quotes inside JavaScript code values, making the
1713
+ // output invalid JSON. Three-pass repair: (1) state-machine newline escape,
1714
+ // (2) standard JSON.parse, (3) line-based raw extractor for stubborn files.
1715
+ //
1716
+ // Paired field convention used by the recorder:
1717
+ // "szField": "" → current/blank value (SKIP — sibling _1 has target)
1718
+ // "szField_1": "Value" → value to type/select (EMIT step)
1719
+ // Same pattern applies to list*, txt*, inputInput*, reason*, typeaheaddropdown*.
1720
+ // ── JSON repair utilities ─────────────────────────────────────────────────────
1721
+ /** State-machine pass: escape literal \n and \r inside JSON string values. */
1722
+ function repairJsonStringLiterals(raw) {
1723
+ let out = '';
1724
+ let inStr = false;
1725
+ let esc = false;
1726
+ for (let i = 0; i < raw.length; i++) {
1727
+ const c = raw[i];
1728
+ if (esc) {
1729
+ out += c;
1730
+ esc = false;
1731
+ continue;
1732
+ }
1733
+ if (c === '\\') {
1734
+ esc = true;
1735
+ out += c;
1736
+ continue;
1737
+ }
1738
+ if (c === '"') {
1739
+ inStr = !inStr;
1740
+ out += c;
1741
+ continue;
1742
+ }
1743
+ if (inStr && c === '\n') {
1744
+ out += '\\n';
1745
+ continue;
1746
+ }
1747
+ if (inStr && c === '\r') {
1748
+ out += '\\r';
1749
+ continue;
1750
+ }
1751
+ out += c;
1752
+ }
1753
+ return out;
1754
+ }
1755
+ /**
1756
+ * Standard JSON.parse, falling back to repairJsonStringLiterals() on failure
1757
+ * (Passes 1+2 of tryParseJsonCases's 3-pass chain below). Exported so other
1758
+ * routes that parse the same real-world-messy FZE JSON exports — e.g.
1759
+ * packages/api/src/routes/functionize.ts, which previously did a bare
1760
+ * JSON.parse with no fallback and would throw on the exact literal-newline/
1761
+ * unescaped-quote files this repairs — don't need their own copy of this
1762
+ * logic. Pass 3 (extractFzeRawPairs + parseFunctionizeCases) is intentionally
1763
+ * NOT included here: it returns ParsedTestCase[], a different shape than
1764
+ * functionize.ts's own per-key step decoder expects, so wiring it in would
1765
+ * need a larger rewrite of that route's data flow — left as a follow-up.
1766
+ * See docs/101-GAPS-FUNCTIONIZE-MIGRATION-PIPELINE.md §3.1.
1767
+ */
1768
+ function parseJsonWithRepair(raw) {
1769
+ try {
1770
+ return JSON.parse(raw);
1771
+ }
1772
+ catch {
1773
+ return JSON.parse(repairJsonStringLiterals(raw));
1774
+ }
1775
+ }
1776
+ /**
1777
+ * Line-based raw pair extractor — last-resort fallback for files with unescaped
1778
+ * interior double-quotes inside code/conditional values (e.g. `"qa"` inside a
1779
+ * string). Parses the FZE structure directly without relying on JSON.parse.
1780
+ * Returns one Record<string,string> per JSON object found in the input.
1781
+ */
1782
+ function extractFzeRawPairs(content) {
1783
+ const lines = content.split('\n').map((l) => l.replace(/\r$/, ''));
1784
+ const allObjects = [];
1785
+ let currentObj = null;
1786
+ let currentKey = null;
1787
+ let currentValueLines = [];
1788
+ const flushValue = () => {
1789
+ if (currentKey !== null && currentObj !== null) {
1790
+ if (currentValueLines.length > 0) {
1791
+ currentValueLines[currentValueLines.length - 1] =
1792
+ currentValueLines[currentValueLines.length - 1].replace(/",?\s*$/, '');
1793
+ }
1794
+ currentObj[currentKey] = currentValueLines.join('\n');
1795
+ }
1796
+ currentKey = null;
1797
+ currentValueLines = [];
1798
+ };
1799
+ const isNewKeyLine = (l) => /^\s*"[^"]+"\s*:/.test(l);
1800
+ const isStructural = (t) => t === '[' || t === ']' || t === '{' || t === '}' || t === '},' || t === '},';
1801
+ for (let i = 0; i < lines.length; i++) {
1802
+ const line = lines[i];
1803
+ const trimmed = line.trim();
1804
+ const nextTrimmed = (lines[i + 1] ?? '').trim();
1805
+ if (isStructural(trimmed)) {
1806
+ flushValue();
1807
+ if (trimmed === '{') {
1808
+ currentObj = {};
1809
+ allObjects.push(currentObj);
1810
+ }
1811
+ continue;
1812
+ }
1813
+ // Compact bracket+brace forms: "[{", "[ {", "},{", "}, {" on the same line.
1814
+ // Functionize exports sometimes omit the newline between "[" and "{".
1815
+ if (/^\[\s*\{$/.test(trimmed) || /^\}\s*,\s*\{$/.test(trimmed)) {
1816
+ flushValue();
1817
+ currentObj = {};
1818
+ allObjects.push(currentObj);
1819
+ continue;
1820
+ }
1821
+ const kvMatch = line.match(/^\s*"([^"]+)":\s*"(.*)/);
1822
+ if (kvMatch && currentObj !== null) {
1823
+ flushValue();
1824
+ currentKey = kvMatch[1];
1825
+ const rest = kvMatch[2];
1826
+ const nextIsContext = isNewKeyLine(nextTrimmed) || isStructural(nextTrimmed);
1827
+ if (nextIsContext || /",?\s*$/.test(rest)) {
1828
+ currentObj[currentKey] = rest.replace(/",?\s*$/, '');
1829
+ currentKey = null;
1830
+ currentValueLines = [];
1831
+ }
1832
+ else {
1833
+ currentValueLines = [rest];
1834
+ }
1835
+ continue;
1836
+ }
1837
+ if (currentKey !== null && currentObj !== null) {
1838
+ const nextIsContext = isNewKeyLine(nextTrimmed) || isStructural(nextTrimmed);
1839
+ if (nextIsContext) {
1840
+ currentValueLines.push(line.replace(/",?\s*$/, ''));
1841
+ flushValue();
1842
+ }
1843
+ else {
1844
+ currentValueLines.push(line);
1845
+ }
1846
+ }
1847
+ }
1848
+ flushValue();
1849
+ return allObjects.filter((o) => Object.keys(o).length > 0);
1850
+ }
1851
+ // ── Format detection ──────────────────────────────────────────────────────────
1852
+ /**
1853
+ * Detect Functionize / FZE recording format — a JSON array of flat key→value
1854
+ * maps representing a recorded browser session. Matches on the most distinctive
1855
+ * key patterns present across all known FZE file variants.
1856
+ */
1857
+ function isFunctionizeFormat(arr) {
1858
+ if (!arr.length)
1859
+ return false;
1860
+ const first = arr[0];
1861
+ if (typeof first !== 'object' || first === null || Array.isArray(first))
1862
+ return false;
1863
+ const keys = Object.keys(first);
1864
+ return keys.some(k => /^pageinit/.test(k) ||
1865
+ /^customcode/.test(k) ||
1866
+ /^contextswitch/.test(k) ||
1867
+ k === 'setVariable' ||
1868
+ k === 'loginbtn' ||
1869
+ /^sz[A-Z]/.test(k));
1870
+ }
1871
+ /** Convert camelCase / underscore identifiers to human-readable form. */
1872
+ function fzeHumanize(s) {
1873
+ // Expand common abbreviations before splitting
1874
+ const expanded = s
1875
+ .replace(/\bOppty\b/gi, 'Opportunity')
1876
+ .replace(/\bMgmt\b/gi, 'Management')
1877
+ .replace(/\bFrm\b/gi, 'Form')
1878
+ .replace(/\bDtl(s)?\b/gi, 'Details')
1879
+ .replace(/\bCust\b/gi, 'Customer')
1880
+ .replace(/\bProsp(ect)?\b/gi, 'Prospect')
1881
+ .replace(/\bAuth\b/gi, 'Authentication')
1882
+ .replace(/\bConf\b/gi, 'Configuration')
1883
+ .replace(/\bAdmin\b/gi, 'Administration')
1884
+ .replace(/\bDashb(oard)?\b/gi, 'Dashboard');
1885
+ const spaced = expanded
1886
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
1887
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
1888
+ .replace(/[_-]+/g, ' ')
1889
+ .trim();
1890
+ return spaced.replace(/\b\w/g, (c) => c.toUpperCase());
1891
+ }
1892
+ /** True when a value is a code reference rather than a concrete test data value.
1893
+ * Env/random/local tokens should NOT be stored in formData — the linked env config DataSet
1894
+ * provides actual values, and storing env refs alongside them causes duplicate display. */
1895
+ function fzeIsDynamic(v) {
1896
+ return /^dynamic/i.test(v) || /^\{\{\s*fze\.(env|random|local|project)\./i.test(v);
1897
+ }
1898
+ /** Convert FZE template tokens to readable display strings for step text. */
1899
+ function humanizeFzeToken(s) {
1900
+ return s
1901
+ .replace(/\{\{\s*fze\.randomText\((\d+)\)\s*\}\}/gi, (_, n) => `[random ${n}-char text]`)
1902
+ .replace(/\{\{\s*fze\.randomString\((\d+)\)\s*\}\}/gi, (_, n) => `[random ${n}-char text]`)
1903
+ .replace(/\{\{\s*fze\.randomEmail\s*\(\s*(?:'([^']*)'|"([^"]*)")?\s*\)\s*\}\}/gi, (_, d1, d2) => `[random email @${d1 || d2 || 'example.com'}]`)
1904
+ .replace(/\{\{\s*fze\.randomPhone\s*\(\s*\)\s*\}\}/gi, '[random phone]')
1905
+ .replace(/\{\{\s*fze\.randomDoB\s*\([^)]*\)\s*\}\}/gi, '[random date of birth]')
1906
+ .replace(/\{\{\s*fze\.randomFirstName\s*\(\s*\)\s*\}\}/gi, '[random first name]')
1907
+ .replace(/\{\{\s*fze\.randomLastName\s*\(\s*\)\s*\}\}/gi, '[random last name]')
1908
+ .replace(/\{\{\s*fze\.randomNumber\s*\([^)]*\)\s*\}\}/gi, '[random number]')
1909
+ .replace(/\{\{\s*fze\.uuid\s*\(\s*\)\s*\}\}/gi, '[random UUID]')
1910
+ .replace(/\{\{\s*fze\.timestamp\s*\(\s*\)\s*\}\}/gi, '[timestamp]')
1911
+ .replace(/\{\{\s*fze\.date\s*\(\s*\)\s*\}\}/gi, '[today date]')
1912
+ .replace(/\{\{\s*fze\.local\.([^}\s]+)\s*\}\}/gi, (_, v) => `[captured: ${v}]`);
1913
+ }
1914
+ /** Pick the selected value from a multi-line dropdown options string. */
1915
+ function pickFzeSelection(raw) {
1916
+ const lines = raw.split(/[\r\n]+/).map(l => l.trim()).filter(Boolean);
1917
+ const skip = /^(-{0,2}select|--choose|select all)/i;
1918
+ return (lines.find(l => !skip.test(l)) ?? lines[0] ?? raw).split('\n')[0].trim();
1919
+ }
1920
+ function sanitizeFzeKey(raw) {
1921
+ const clean = raw
1922
+ .replace(/FUNCOPR|FUNCVAL|FUNCOPREND|FUNCVALEND/g, '')
1923
+ .replace(/[^a-zA-Z0-9\s]/g, ' ')
1924
+ .trim();
1925
+ if (!clean)
1926
+ return 'field';
1927
+ const words = clean.split(/\s+/).filter(Boolean);
1928
+ return words[0].charAt(0).toLowerCase() + words[0].slice(1) +
1929
+ words.slice(1).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
1930
+ }
1931
+ /**
1932
+ * Decode one Functionize key→value pair into a human-readable step descriptor.
1933
+ * Returns null for keys that should be silently skipped (e.g. paired-field bases
1934
+ * whose _1 sibling carries the actual value).
1935
+ *
1936
+ * @param key Raw FZE key string
1937
+ * @param value String value (already trimmed)
1938
+ * @param allKeys Full list of keys in the current recording object — used for
1939
+ * paired-field detection (szField + szField_1 pattern).
1940
+ */
1941
+ function decodeFzeKey(key, value, allKeys) {
1942
+ // ── Navigation ─────────────────────────────────────────────────────────────
1943
+ if (/^pageinit/.test(key) || key === 'navigate') {
1944
+ return value ? { action: `Navigate to: ${value}` } : null;
1945
+ }
1946
+ // ── Custom JavaScript / variable assignment ────────────────────────────────
1947
+ if (/^customcode/.test(key) || key === 'setVariable') {
1948
+ if (!value)
1949
+ return null;
1950
+ const summary = value.split('\n')[0].trim().slice(0, 100).replace(/\s+/g, ' ');
1951
+ return { action: `Execute script (${key}): ${summary}`, isScript: true };
1952
+ }
1953
+ // ── Context / iframe / window switch ──────────────────────────────────────
1954
+ if (/^contextswitch/.test(key)) {
1955
+ return { action: value ? `Switch context: "${value}"` : 'Switch context', isContextSwitch: true };
1956
+ }
1957
+ // ── Conditional / branching ────────────────────────────────────────────────
1958
+ if (/^conditional/.test(key)) {
1959
+ return value ? { action: `[Conditional] ${value.slice(0, 150)}`, isConditional: true } : null;
1960
+ }
1961
+ // ── Credentials ────────────────────────────────────────────────────────────
1962
+ // Username shown as-is (test account, not PII). Password always masked.
1963
+ if (key === 'user')
1964
+ return { action: value ? `Enter username: "${value}"` : 'Enter username', isCredential: true, inputField: 'username' };
1965
+ if (key === 'Password')
1966
+ return { action: `Enter password: [protected]`, isCredential: true };
1967
+ if (key === 'loginbtn')
1968
+ return value ? { action: `Click login button: "${value}"`, isCredential: true } : null;
1969
+ // ── ASP.NET ctl* controls ──────────────────────────────────────────────────
1970
+ if (key.startsWith('ctl')) {
1971
+ const m = key.match(/(txt|btn|ddl|chk|rad)([A-Z][A-Za-z0-9]+?)(?:SF)?(?:_\d+)?$/);
1972
+ if (m) {
1973
+ const [, type, rawName] = m;
1974
+ const fieldName = fzeHumanize(rawName);
1975
+ const locator = rawName;
1976
+ if (type === 'btn')
1977
+ return value ? { action: `Click button: "${value}"`, locator } : null;
1978
+ if (type === 'ddl')
1979
+ return value ? { action: `Select "${value}" from ${fieldName} dropdown`, locator, inputField: rawName } : null;
1980
+ if (type === 'txt')
1981
+ return value ? { action: `Enter "${value}" in ${fieldName} field`, locator, inputField: rawName } : null;
1982
+ if (type === 'chk')
1983
+ return { action: `${value === 'on' || value === 'true' ? 'Check' : 'Uncheck'} "${fieldName}" checkbox`, locator };
1984
+ if (type === 'rad')
1985
+ return value ? { action: `Select radio option "${value}" for ${fieldName}`, locator } : null;
1986
+ }
1987
+ return value ? { action: `Set form control "${fzeHumanize(key)}": "${value}"` } : null;
1988
+ }
1989
+ // ── Paired-field base detection ────────────────────────────────────────────
1990
+ // Keys like szField, listField, txtField, inputInput, reason, typeaheaddropdown
1991
+ // come in pairs: base = current/options, base_1 = target value to set.
1992
+ // Skip the base if its _1 sibling exists (will be processed when _1 is reached).
1993
+ const pairedBaseRe = /^(sz|list|lSource[A-Z]|l[A-Z][a-z]|txt[A-Z]|inputInput|inputINPUT|reason|typeaheaddropdown|multiselectdropdown)(.*)$/;
1994
+ if (pairedBaseRe.test(key) && !/_\d+$/.test(key) && allKeys.includes(`${key}_1`))
1995
+ return null;
1996
+ // ── sz* — CRM text/input fields (prospect, customer, vehicle data) ─────────
1997
+ if (/^sz[A-Z]/.test(key)) {
1998
+ if (!value)
1999
+ return null;
2000
+ const baseName = key.replace(/^sz/, '').replace(/_\d+$/, '');
2001
+ const fieldName = fzeHumanize(baseName);
2002
+ // Strip sz prefix; use same PascalCase rawName as ctl*txt so 'szZip' → 'Zip'.
2003
+ // Aligns with ctl*txt rawName keys and deduplicates when both patterns appear.
2004
+ const inputField = baseName;
2005
+ return { action: `Enter "${value}" in ${fieldName} field`, inputField };
2006
+ }
2007
+ // ── txt* — generic text inputs (txtSsn, txtBirthday, txtQuickSearch…) ─────
2008
+ if (/^txt[A-Z]/.test(key)) {
2009
+ if (!value)
2010
+ return null;
2011
+ const locator = key.replace(/_\d+$/, '');
2012
+ const fieldName = fzeHumanize(locator.replace(/^txt/, ''));
2013
+ return { action: `Enter "${value}" in ${fieldName} field`, locator, inputField: locator };
2014
+ }
2015
+ // ── list* / lSource* / l[A-Z] — dropdown selections ───────────────────────
2016
+ if (/^(list|lSource[A-Z]|l[A-Z])/.test(key)) {
2017
+ if (!value)
2018
+ return null;
2019
+ const baseName = key.replace(/_\d+$/, '');
2020
+ const fieldName = fzeHumanize(baseName.replace(/^list/, '').replace(/^l/, ''));
2021
+ const selection = pickFzeSelection(value);
2022
+ return { action: `Select "${selection}" from ${fieldName} dropdown`, inputField: baseName };
2023
+ }
2024
+ // ── inputInput / inputINPUT — generic typed inputs ─────────────────────────
2025
+ if (/^input(Input|INPUT)/.test(key)) {
2026
+ if (!value)
2027
+ return null;
2028
+ return { action: `Type in input field: "${value}"`, inputField: 'input' };
2029
+ }
2030
+ // ── typeaheaddropdown — typeahead / autocomplete ───────────────────────────
2031
+ if (/^typeaheaddropdown/.test(key)) {
2032
+ if (!value)
2033
+ return null;
2034
+ const selection = pickFzeSelection(value);
2035
+ return { action: `Type and select: "${selection}"`, inputField: 'typeahead' };
2036
+ }
2037
+ // ── multiselectdropdown — multi-select filter fields ──────────────────────
2038
+ if (/^multiselectdropdown/.test(key)) {
2039
+ if (!value)
2040
+ return null;
2041
+ // FZE encodes multi-select as "OR|FUNCOPR|Val1|FUNCVAL|Val2"
2042
+ const options = value
2043
+ .replace(/OR\|FUNCOPR\|/g, '')
2044
+ .replace(/\|FUNCVAL\|/g, ', ')
2045
+ .replace(/\|FUNCOPR\|/g, ', ');
2046
+ return { action: `Multi-select: ${options}`, inputField: 'multiselect' };
2047
+ }
2048
+ // ── cb* — checkboxes ───────────────────────────────────────────────────────
2049
+ if (/^cb[A-Z]/.test(key)) {
2050
+ const fieldName = fzeHumanize(key.replace(/^cb/, ''));
2051
+ const checked = value === 'on' || value === 'true' || value === '1';
2052
+ return { action: `${checked ? 'Check' : 'Uncheck'} "${fieldName}" checkbox`, locator: key };
2053
+ }
2054
+ // ── radio* — radio button selections ──────────────────────────────────────
2055
+ if (/^radio/.test(key)) {
2056
+ if (!value)
2057
+ return null;
2058
+ const fieldName = fzeHumanize(key.replace(/^radio/, ''));
2059
+ return { action: `Select radio option "${value}"${fieldName ? ` for ${fieldName}` : ''}`, locator: key };
2060
+ }
2061
+ // ── reason* — reason / comment textareas ──────────────────────────────────
2062
+ if (/^reason/.test(key)) {
2063
+ if (!value)
2064
+ return null;
2065
+ return { action: `Enter reason: "${value}"`, inputField: 'reason' };
2066
+ }
2067
+ // ── textareadefault — generic textarea ────────────────────────────────────
2068
+ if (/^textareadefault/.test(key)) {
2069
+ return value ? { action: `Type in text area: "${value}"`, inputField: 'textarea' } : null;
2070
+ }
2071
+ // ── btn* — standalone buttons (not ASP.NET ctl) ───────────────────────────
2072
+ if (/^btn[A-Z]/.test(key)) {
2073
+ return value ? { action: `Click button: "${value}"`, locator: key } : null;
2074
+ }
2075
+ // ── select* — explicit select dropdowns ───────────────────────────────────
2076
+ if (/^select[A-Z]/.test(key)) {
2077
+ if (!value)
2078
+ return null;
2079
+ const fieldName = fzeHumanize(key.replace(/^select/, '').replace(/_\d+$/, ''));
2080
+ return { action: `Select "${value}" from ${fieldName || 'dropdown'}`, inputField: key.replace(/_\d+$/, '') };
2081
+ }
2082
+ // ── Click actions ──────────────────────────────────────────────────────────
2083
+ if (key.startsWith('click')) {
2084
+ const sub = key.slice(5); // everything after "click"
2085
+ // Anchor / hyperlinks
2086
+ if (/^A([_$\d]|$)/.test(sub))
2087
+ return { action: value ? `Click link: "${value}"` : 'Click link' };
2088
+ // Buttons
2089
+ if (/^Button/.test(sub)) {
2090
+ const locator = sub.replace(/_\d+$/, '');
2091
+ return { action: value ? `Click button: "${value}"` : 'Click button', locator };
2092
+ }
2093
+ // Tabs
2094
+ if (/^Tab/.test(sub))
2095
+ return { action: value ? `Click tab: "${value}"` : 'Click tab' };
2096
+ // Images / icons
2097
+ if (/^Img/.test(sub))
2098
+ return { action: value ? `Click image: "${value}"` : 'Click image' };
2099
+ if (/^Svg/.test(sub))
2100
+ return { action: value ? `Click icon: "${value}"` : 'Click icon' };
2101
+ // Spans
2102
+ if (/^Span/.test(sub) || /^SPAN/.test(sub))
2103
+ return { action: value ? `Click: "${value}"` : 'Click element' };
2104
+ // Text nodes
2105
+ if (/^Text/.test(sub))
2106
+ return value ? { action: `Click text: "${value}"` } : null;
2107
+ // Links (Lnk prefix)
2108
+ if (/^Lnk/.test(sub)) {
2109
+ const lnkName = fzeHumanize(sub.replace(/^Lnk/, '').replace(/_\d+$/, ''));
2110
+ return { action: value ? `Click link: "${value}"` : `Click ${lnkName} link` };
2111
+ }
2112
+ // Logout/legacy buttons (Lb prefix)
2113
+ if (/^Lb/.test(sub))
2114
+ return { action: value ? `Click: "${value}"` : `Click ${fzeHumanize(sub.replace(/^Lb/, ''))}` };
2115
+ // Bare B (button/bold element)
2116
+ if (/^B([_$\d]|$)/.test(sub))
2117
+ return { action: value ? `Click: "${value}"` : 'Click button' };
2118
+ // List items
2119
+ if (/^Li([_$\d]|$)/.test(sub))
2120
+ return value ? { action: `Click list item: "${value}"` } : null;
2121
+ // Input clicks
2122
+ if (/^(INPUT|In(put|PUT)?)([_$\d]|$)/.test(sub))
2123
+ return { action: value ? `Click input: "${value}"` : 'Click input' };
2124
+ // Brand logo / app switcher
2125
+ if (/^BrandImage/.test(sub))
2126
+ return { action: value ? `Click brand logo: "${value}"` : 'Click brand logo' };
2127
+ // Checkbox via click
2128
+ if (/^Ckb/.test(sub)) {
2129
+ const fieldName = fzeHumanize(sub.replace(/^Ckb/, '').replace(/_\d+$/, ''));
2130
+ return { action: `${value === 'on' || !value ? 'Check' : 'Uncheck'} "${fieldName}"`, locator: sub };
2131
+ }
2132
+ // Multi-lender / desking filter chips
2133
+ if (/^DMulti/.test(sub))
2134
+ return value ? { action: `Click filter: "${value}"` } : null;
2135
+ // Menu navigation (clickMenuSections_SectionLabel_2, etc.)
2136
+ if (/^MenuSections/.test(sub)) {
2137
+ return { action: value ? `Navigate menu: "${value}"` : `Click nav: ${fzeHumanize(sub.replace(/^MenuSections_/, ''))}` };
2138
+ }
2139
+ // Dashboard / reporting elements
2140
+ if (/^Dashboard/.test(sub) || /^Dashboard/i.test(sub)) {
2141
+ return { action: value ? `Click dashboard element: "${value}"` : 'Click dashboard element' };
2142
+ }
2143
+ // Table cells / rows
2144
+ if (/^Td/.test(sub))
2145
+ return value ? { action: `Click table cell: "${value}"` } : null;
2146
+ // Divs — filter dropdown noise
2147
+ if (/^Div/.test(sub)) {
2148
+ const sel = pickFzeSelection(value);
2149
+ return { action: sel ? `Click: "${sel}"` : 'Click element' };
2150
+ }
2151
+ // Generic fallback
2152
+ const elType = sub.replace(/_\d+$/, '').replace(/([A-Z])/g, ' $1').trim();
2153
+ return { action: value ? `Click ${elType}: "${value}"` : `Click ${elType}` };
2154
+ }
2155
+ // ── Assertions (verify*) ──────────────────────────────────────────────────
2156
+ if (key.startsWith('verify')) {
2157
+ if (!value)
2158
+ return null;
2159
+ // Decode FUNCOPR multi-value encoding (e.g. "OR|FUNCOPR|Dark Mode|FUNCVAL|") before display
2160
+ const displayValue = value.includes('FUNCOPR') || value.includes('FUNCVAL')
2161
+ ? value
2162
+ .replace(/^OR\|FUNCOPR\|/, '')
2163
+ .replace(/\|FUNCVAL\|([^|]*)\|?/g, (_, b) => b.trim() ? ` or ${b.trim()}` : '')
2164
+ .replace(/\|FUNCOPR\|/g, ', ')
2165
+ .trim()
2166
+ : value;
2167
+ // Extract assertion property suffix
2168
+ const propMatch = key.match(/_(text|url|value|attr_id|visibility|attr_[a-z_]+)(\d+)?$/i);
2169
+ const prop = propMatch?.[1]?.toLowerCase() ?? 'text';
2170
+ // Extract locator: strip "verify" prefix and property suffix + index
2171
+ const locator = key
2172
+ .replace(/^verify/, '')
2173
+ .replace(/_(text|url|value|attr_id|visibility|attr_[a-z_]+)\d*$/i, '')
2174
+ .replace(/_\d+$/, '');
2175
+ // URL assertion
2176
+ if (prop === 'url')
2177
+ return { action: `Verify URL contains: "${displayValue}"`, isAssertion: true, locator };
2178
+ // Visibility assertion
2179
+ if (prop === 'visibility') {
2180
+ return { action: `Verify "${locator || 'element'}" is ${value === 'true' ? 'visible' : 'hidden'}`, isAssertion: true, locator };
2181
+ }
2182
+ // Attribute assertions
2183
+ if (prop === 'attr_id')
2184
+ return { action: `Verify element attribute id: "${displayValue}"`, isAssertion: true, locator };
2185
+ if (prop.startsWith('attr_')) {
2186
+ const attrName = prop.slice(5);
2187
+ return { action: `Verify ${attrName} attribute: "${displayValue}"`, isAssertion: true, locator };
2188
+ }
2189
+ // Value assertions (input field values, incl. dynamic variable refs)
2190
+ if (prop === 'value') {
2191
+ const isDyn = fzeIsDynamic(value);
2192
+ return { action: `Verify field value: "${displayValue}"${isDyn ? ' (captured dynamic value)' : ''}`, isAssertion: true, locator };
2193
+ }
2194
+ // Hash-based locators (e.g. verify6b300a51cba242d1834a775414fb51ffheading_text)
2195
+ if (/^[0-9a-f]{10,}/i.test(locator)) {
2196
+ const suffix = locator.replace(/^[0-9a-f]+/i, '');
2197
+ return { action: `Verify ${fzeHumanize(suffix) || 'heading'}: "${displayValue}"`, isAssertion: true };
2198
+ }
2199
+ // Breadcrumb / dashboard title / page-level assertions
2200
+ if (/^BreadcrumbContainer/i.test(locator))
2201
+ return { action: `Verify breadcrumb: "${displayValue}"`, isAssertion: true };
2202
+ if (/^DashboardTitle/i.test(locator))
2203
+ return { action: `Verify dashboard title: "${displayValue}"`, isAssertion: true };
2204
+ if (/^Top$/i.test(locator))
2205
+ return { action: `Verify page header contains: "${displayValue.split('\n')[0].trim()}"`, isAssertion: true };
2206
+ if (/^CurrentDashBoardSection/i.test(locator))
2207
+ return { action: `Verify current section: "${displayValue}"`, isAssertion: true };
2208
+ // Standard element text assertion
2209
+ const elMatch = locator.match(/^([A-Z][a-z]+)/);
2210
+ const elType = elMatch?.[1] ?? 'element';
2211
+ return { action: `Verify ${fzeHumanize(elType)} text: "${displayValue}"`, isAssertion: true, locator };
2212
+ }
2213
+ // Unknown key: emit generic step so nothing is silently dropped
2214
+ if (value) {
2215
+ const humanKey = fzeHumanize(key.replace(/_\d+$/, '').replace(/([A-Z])/g, ' $1').trim().replace(/\s+/g, ' '));
2216
+ return { action: `${humanKey}: "${value.slice(0, 200)}"` };
2217
+ }
2218
+ return null;
2219
+ }
2220
+ // ── Excel FZE parser ─────────────────────────────────────────────────────────
2221
+ // Converts a pipe-table (extracted from .xlsx) into FZE objects and parses them.
2222
+ // Supports two layouts:
2223
+ // Multi-column: first row = all FZE action keys, each data row = one test case
2224
+ // Transposed 2-col: col A = key name, col B = value — all rows = one test case
2225
+ function tryParseFzeExcel(content, sourcePlatform) {
2226
+ const parseCells = (line) => line.split('|').slice(1, -1).map(c => c.trim());
2227
+ const isFzeKey = (s) => /^(pageinit|customcode|user|Password|loginbtn|contextswitch|conditional|setVariable|sz[A-Z]|txt[A-Z]|list|lSource|verify|click|inputInput|inputINPUT|btn[A-Z]|typeaheaddropdown|multiselectdropdown|navigate|radio|reason|cb[A-Z]|select[A-Z]|textareadefault|ctl)/i.test(s.trim());
2228
+ const lines = content.split('\n')
2229
+ .map(l => l.trim())
2230
+ .filter(l => l.startsWith('|') && !l.match(/^[\s|:-]+$/));
2231
+ if (lines.length < 2)
2232
+ return null;
2233
+ // Find the first line where col 0 is pageinit or an FZE key (resilient to leading sheet/title/header rows)
2234
+ const firstFzeIdx = lines.findIndex(l => {
2235
+ const firstCell = parseCells(l)[0]?.toLowerCase() ?? '';
2236
+ return firstCell === 'pageinit' || isFzeKey(firstCell);
2237
+ });
2238
+ if (firstFzeIdx === -1)
2239
+ return null;
2240
+ const candidateLines = lines.slice(firstFzeIdx);
2241
+ const headers = parseCells(candidateLines[0]);
2242
+ // Detect transposed (2-column) vs multi-column by checking if candidateLines[1] col-0 is an FZE key
2243
+ const isTransposed = candidateLines.length > 1 && isFzeKey(parseCells(candidateLines[1] ?? '')[0] ?? '');
2244
+ let fzeObjects;
2245
+ if (isTransposed) {
2246
+ const obj = {};
2247
+ for (const line of candidateLines) {
2248
+ const cells = parseCells(line);
2249
+ const key = cells[0] ?? '';
2250
+ if (key)
2251
+ obj[key] = cells[1] ?? '';
2252
+ }
2253
+ fzeObjects = [obj];
2254
+ }
2255
+ else {
2256
+ fzeObjects = candidateLines.slice(1).map(line => {
2257
+ const cells = parseCells(line);
2258
+ const obj = {};
2259
+ headers.forEach((h, i) => { if (h)
2260
+ obj[h] = cells[i] ?? ''; });
2261
+ return obj;
2262
+ });
2263
+ }
2264
+ if (!fzeObjects.length)
2265
+ return null;
2266
+ return parseFunctionizeCases(fzeObjects, sourcePlatform);
2267
+ }
2268
+ // ── Main case builder ─────────────────────────────────────────────────────────
2269
+ function parseFunctionizeCases(arr, sourcePlatform) {
2270
+ const cases = [];
2271
+ const errors = [];
2272
+ for (let idx = 0; idx < arr.length; idx++) {
2273
+ const rec = arr[idx];
2274
+ if (typeof rec !== 'object' || rec === null || Array.isArray(rec))
2275
+ continue;
2276
+ const allKeys = Object.keys(rec);
2277
+ // Capture explicit test case ID if present in the Functionize record
2278
+ const explicitRef = rec.id != null ? String(rec.id)
2279
+ : rec.testId != null ? String(rec.testId)
2280
+ : rec.testCaseId != null ? String(rec.testCaseId)
2281
+ : rec.test_case_id != null ? String(rec.test_case_id)
2282
+ : rec['TC ID'] != null ? String(rec['TC ID'])
2283
+ : rec['TC_ID'] != null ? String(rec['TC_ID'])
2284
+ : null;
2285
+ const steps = [];
2286
+ const customScripts = [];
2287
+ const formData = {};
2288
+ const credentials = {};
2289
+ const urls = [];
2290
+ const selectionValues = [];
2291
+ let lastAssertion = '';
2292
+ let contextSwitchCount = 0;
2293
+ for (const key of allKeys) {
2294
+ const rawVal = rec[key];
2295
+ const value = String(rawVal ?? '').trim();
2296
+ // Skip metadata keys already captured (IDs, etc.)
2297
+ if (key === 'id' || key === 'testId' || key === 'testCaseId' || key === 'test_case_id' || key === 'TC ID' || key === 'TC_ID')
2298
+ continue;
2299
+ // Collect credentials for testDataInline, but still emit steps for locator extraction.
2300
+ // decodeFzeKey handles these with masked action text (real password never in step text).
2301
+ if (key === 'user') {
2302
+ if (value)
2303
+ credentials.username = value;
2304
+ }
2305
+ if (key === 'Password') {
2306
+ credentials.hasPassword = 'true';
2307
+ if (value)
2308
+ credentials.password = value;
2309
+ }
2310
+ // Collect and record custom scripts in testDataInline
2311
+ if (/^customcode/.test(key) || key === 'setVariable') {
2312
+ if (value) {
2313
+ customScripts.push({ name: key, code: value });
2314
+ const summary = value.split('\n')[0].trim().slice(0, 120).replace(/\s+/g, ' ');
2315
+ steps.push({ action: `Execute script (${key}): ${summary}`, data: `script:${key}`, expected: '' });
2316
+ }
2317
+ continue;
2318
+ }
2319
+ // Track all navigation URLs (pageinit + navigate keys) — include fze.env.* URL placeholders
2320
+ if ((/^pageinit/.test(key) || key === 'navigate') && value && (value.startsWith('http') || /^\{\{\s*fze\.env\./i.test(value)))
2321
+ urls.push(value);
2322
+ // Context switches: enrich step text with iframe selector hints so script-agent
2323
+ // can generate a real switchToFrame() call instead of a stub.
2324
+ // NOTE: Many FZE recordings use empty-string contextswitch values — the empty string
2325
+ // means "switch context here" with no explicit frame name. We ALWAYS emit a step
2326
+ // (even for empty values) so no context switch is silently dropped.
2327
+ if (/^contextswitch/.test(key)) {
2328
+ contextSwitchCount++;
2329
+ let contextHint = '';
2330
+ if (!value || value === 'main' || value === 'default' || value === 'top') {
2331
+ // Empty or "main" — infer from surrounding URL context.
2332
+ // When contextswitch is empty, Functionize marks a frame/window boundary with no explicit name.
2333
+ // Look at the most recent URLs to determine if we're entering an embed (e.g. Looker) or returning.
2334
+ const lastUrl = urls.length > 0 ? urls[urls.length - 1] : '';
2335
+ const embedUrl = urls.find(u => /looker|embed|iframe|frame|chart|analytics|dashboard/.test(u));
2336
+ if (!value || value === 'main' || value === 'default' || value === 'top') {
2337
+ // Empty = entering or leaving a frame boundary; check if we have an embed URL context
2338
+ if (embedUrl) {
2339
+ try {
2340
+ const embedHost = new URL(embedUrl).hostname;
2341
+ // Alternating empty contextswitches = enter/exit the embed iframe
2342
+ const isEven = contextSwitchCount % 2 === 1; // odd = enter, even = exit
2343
+ if (isEven) {
2344
+ contextHint = ` (enter embedded frame — use switchToFrame('iframe[src*="${embedHost}"]'))`;
2345
+ }
2346
+ else {
2347
+ contextHint = ` (exit embedded frame — use switchToDefaultContent() or page.mainFrame())`;
2348
+ }
2349
+ }
2350
+ catch { /* ignore */ }
2351
+ }
2352
+ else if (lastUrl) {
2353
+ // Determine popup vs iframe from URL patterns.
2354
+ // Popup/new-tab: kiosk forms, explicit popup/window path segments, different-host navigations.
2355
+ // Iframe (default): same-host ASP.NET frames, embedded content without popup markers.
2356
+ const isPopupPattern = /kiosk|popup|newwindow|new[_-]?window|new[_-]?tab|modal|_frm_new|opendialog/i.test(lastUrl);
2357
+ const shortUrl = lastUrl.replace(/^https?:\/\//, '').slice(0, 60);
2358
+ if (isPopupPattern) {
2359
+ contextHint = ` (new tab/popup — use switchToPopup(); last URL: ${shortUrl})`;
2360
+ }
2361
+ else {
2362
+ contextHint = ` (iframe boundary after ${shortUrl} — use switchToFrame('iframe'))`;
2363
+ }
2364
+ }
2365
+ else {
2366
+ contextHint = ' (return to main frame — use switchToDefaultContent())';
2367
+ }
2368
+ }
2369
+ steps.push({ action: `Switch to context: "${value || 'unnamed'}"${contextHint}`, expected: '' });
2370
+ }
2371
+ else if (/^\d+$/.test(value)) {
2372
+ const idx = parseInt(value, 10);
2373
+ steps.push({ action: `Switch to context: "${value}" — iframe index ${value} (use page.frames()[${idx}] or iframe:nth-of-type(${idx + 1}))`, expected: '' });
2374
+ }
2375
+ else if (value.startsWith('http')) {
2376
+ try {
2377
+ const embedHost = new URL(value).hostname;
2378
+ steps.push({ action: `Switch to context: "${value}" — embedded URL (use iframe[src*="${embedHost}"] as selector)`, expected: '' });
2379
+ }
2380
+ catch {
2381
+ steps.push({ action: `Switch to context: "${value}"`, expected: '' });
2382
+ }
2383
+ }
2384
+ else {
2385
+ // Named context — use the name as iframe[name=] and also check collected embed URLs
2386
+ const embedUrl = urls.find(u => /embed|looker|iframe|frame/.test(u));
2387
+ if (embedUrl) {
2388
+ try {
2389
+ const embedHost = new URL(embedUrl).hostname;
2390
+ contextHint = ` — likely embedded frame (try iframe[src*="${embedHost}"] or iframe[name="${value}"])`;
2391
+ }
2392
+ catch { /* ignore */ }
2393
+ }
2394
+ else {
2395
+ contextHint = ` — named frame/window "${value}" (use switchToFrame('iframe[name="${value}"]') or switchToPopup())`;
2396
+ }
2397
+ steps.push({ action: `Switch to context: "${value}"${contextHint}`, expected: '' });
2398
+ }
2399
+ continue;
2400
+ }
2401
+ // Conditionals: emit as if/else step so script-agent generates branching code.
2402
+ // FZE conditional values are JavaScript return-expressions, e.g.:
2403
+ // "return fze.action('ID').resultCode !== 'S' && !location.href.includes('qa');"
2404
+ // They contain ONLY the condition — branch body steps are in subsequent FZE records.
2405
+ if (/^conditional/.test(key)) {
2406
+ if (value) {
2407
+ // Pattern 1: FZE-style "return <bool-expr>;" — extract the expression
2408
+ const returnMatch = value.match(/^return\s+([\s\S]{1,500}?)\s*;?\s*$/i);
2409
+ // Pattern 2: Classic "if (<cond>) { ... }" structure
2410
+ const ifMatch = value.match(/^if\s*\(([^)]{1,200})\)\s*\{?([\s\S]{0,200})/i);
2411
+ if (returnMatch) {
2412
+ const condition = returnMatch[1].trim();
2413
+ // Translate fze.action().resultCode patterns to human-readable form
2414
+ const readable = condition
2415
+ .replace(/fze\.action\(['"]([\w-]+)['"]\)\.resultCode\s*!==?\s*['"](\w+)['"]/g, 'previous action result-code ≠ "$2"')
2416
+ .replace(/fze\.action\(['"]([\w-]+)['"]\)\.resultCode\s*===?\s*['"](\w+)['"]/g, 'previous action result-code = "$2"')
2417
+ .replace(/location\.href\.includes\(['"]([^'"]+)['"]\)/g, 'current URL contains "$1"')
2418
+ .replace(/fze\.local\.(\w+)\.includes\(['"]([^'"]+)['"]\)/g, 'local var $1 contains "$2"');
2419
+ steps.push({
2420
+ action: `[Conditional branch] Condition: ${readable} (raw: ${condition.slice(0, 200)}). Execute next step only if this condition is true — generate an if/else block using page.url(), element visibility, or response assertion for the check.`,
2421
+ expected: '',
2422
+ });
2423
+ }
2424
+ else if (ifMatch) {
2425
+ const [, condition, branch] = ifMatch;
2426
+ const branchSummary = branch.replace(/\s+/g, ' ').trim().slice(0, 120);
2427
+ steps.push({
2428
+ action: `[Conditional branch] if (${condition.trim()}) → ${branchSummary || 'execute branch'}. Generate an if/else block: check element visibility or response value for condition, run branch steps if true, skip or assert otherwise.`,
2429
+ expected: '',
2430
+ });
2431
+ }
2432
+ else {
2433
+ // Fallback: emit full value (up to 500 chars — FZE conditionals are always short JS expressions)
2434
+ steps.push({ action: `[Conditional branch] ${value.slice(0, 500)}. Generate an if/else block for this condition.`, expected: '' });
2435
+ }
2436
+ }
2437
+ continue;
2438
+ }
2439
+ const decoded = decodeFzeKey(key, value, allKeys);
2440
+ if (!decoded)
2441
+ continue;
2442
+ if (decoded.isAssertion && value && !/^\{\{\s*fze\.(local|env|random|uuid)\./i.test(value))
2443
+ lastAssertion = value;
2444
+ // Accumulate form data for testDataInline.
2445
+ // Dynamic refs stored as markers ([env:X], [dynamic], [dynamic:email]) so script generation
2446
+ // can emit process.env.X / Math.random() instead of discarding these fields entirely.
2447
+ if (decoded.inputField && value) {
2448
+ if (fzeIsDynamic(value)) {
2449
+ const envM = value.match(/^\{\{\s*fze\.env\.([^}\s]+?)\s*\}\}$/i);
2450
+ if (envM) {
2451
+ formData[sanitizeFzeKey(decoded.inputField)] = `[env:${envM[1]}]`;
2452
+ }
2453
+ else if (/email/i.test(decoded.inputField)) {
2454
+ formData[sanitizeFzeKey(decoded.inputField)] = '[dynamic:email]';
2455
+ }
2456
+ else {
2457
+ formData[sanitizeFzeKey(decoded.inputField)] = '[dynamic]';
2458
+ }
2459
+ }
2460
+ else if (/^multiselectdropdown/.test(key)) {
2461
+ // Decode FZE multi-select format ("OR|FUNCOPR|Val1|FUNCVAL|Val2") before storing
2462
+ formData[sanitizeFzeKey(decoded.inputField)] = value
2463
+ .replace(/OR\|FUNCOPR\|/g, '')
2464
+ .replace(/\|FUNCVAL\|/g, ', ')
2465
+ .replace(/\|FUNCOPR\|/g, ', ')
2466
+ .trim();
2467
+ }
2468
+ else {
2469
+ const normalizeFzeValue = (v) => v.replace(/\{\{fze\.env\.([^}]+)\}\}/g, '[env:$1]')
2470
+ .replace(/\{\{fze\.randomText\(\d*\)\}\}/g, '[dynamic:text]')
2471
+ .replace(/\{\{fze\.randomEmail\(\)\}\}/g, '[dynamic:email]')
2472
+ .replace(/\{\{fze\.local\.([^}]+)\}\}/g, '[$1]');
2473
+ formData[sanitizeFzeKey(decoded.inputField)] = normalizeFzeValue(value);
2474
+ // Canonicalize email field to consistent key
2475
+ if (/email/i.test(decoded.inputField) && !formData.email) {
2476
+ formData.email = value;
2477
+ }
2478
+ // Also capture common CRM field names for email
2479
+ if (/szEmail|txtEmail|emailAddress|email_address/i.test(decoded.inputField) && !formData.email) {
2480
+ formData.email = value;
2481
+ }
2482
+ }
2483
+ }
2484
+ // Capture dropdown/list-item click selections for test data context
2485
+ if (!decoded.inputField && value && !fzeIsDynamic(value) && value.length < 200) {
2486
+ const clickSub = key.startsWith('click') ? key.slice(5) : '';
2487
+ if (/^(Div|Span|Li)/.test(clickSub)) {
2488
+ const sel = (pickFzeSelection(value) || value).trim();
2489
+ if (sel)
2490
+ selectionValues.push(sel);
2491
+ }
2492
+ else if (/^Button/.test(clickSub)) {
2493
+ // Date range button clicks (e.g. date picker "02/01/2025 - 02/13/2025")
2494
+ if (/\d{1,2}\/\d{1,2}\/\d{4}/.test(value) && !formData.dateRange) {
2495
+ formData.dateRange = value.split('\n')[0].trim().slice(0, 60);
2496
+ }
2497
+ }
2498
+ }
2499
+ let locatorComment;
2500
+ if (decoded.locator) {
2501
+ const semanticHint = tryConvertXPathToSemantic(decoded.locator);
2502
+ if (semanticHint) {
2503
+ locatorComment = `locatorHint: ${semanticHint}`;
2504
+ }
2505
+ else if (decoded.locator.trim().startsWith('/')) {
2506
+ locatorComment = `xpathFallback: ${decoded.locator}`;
2507
+ }
2508
+ else {
2509
+ locatorComment = `locator: ${decoded.locator}`;
2510
+ }
2511
+ }
2512
+ steps.push({
2513
+ action: humanizeFzeToken(decoded.action),
2514
+ data: decoded.inputField && value ? humanizeFzeToken(value) : undefined,
2515
+ expected: decoded.isAssertion ? humanizeFzeToken(value) : '',
2516
+ ...(locatorComment ? { comment: locatorComment } : {}),
2517
+ });
2518
+ }
2519
+ if (steps.length === 0)
2520
+ continue;
2521
+ // Build title from semantic signals — priority: menu navigation > URL title params > URL path segments
2522
+ const realUrls = urls.filter(u => u.startsWith('http'));
2523
+ const firstUrl = realUrls[0] ?? '';
2524
+ let title = `Test Case ${idx + 1}`;
2525
+ const GENERIC_SEGS = new Set(['index', 'viewer', 'render', 'default', 'home', 'main', 'dashboard', 'app', 'fresh', 'evo2', 'login']);
2526
+ // Generic UI states/filters that don't describe WHAT is being tested
2527
+ const GENERIC_MENU_ITEMS = new Set(['active', 'inactive', 'all', 'new', 'home', 'back', 'list', 'search', 'filter', 'none', 'yes', 'no', 'ok', 'cancel']);
2528
+ // Priority 1: menu navigation items — "Navigate menu: X" are the clearest semantic signal
2529
+ const menuItems = steps
2530
+ .filter(s => s.action.startsWith('Navigate menu:'))
2531
+ .map(s => s.action.replace(/^Navigate menu:\s*"?/, '').replace(/"$/, '').trim())
2532
+ .filter(Boolean);
2533
+ // Extract last meaningful assertion — two-pass: expected VALUES first (all steps), then action-name fallback.
2534
+ // Two-pass prevents short abbreviations like "Oppt" (from action text) from blocking a good
2535
+ // expected value ("Home or Accueil") that appears earlier in the step list.
2536
+ const lastAssertEl = (() => {
2537
+ const TRIVIAL_EXPECTED = /^(svg|home|index|true|false|\d+|refresh|active|prospects|inactive|all|new|accueil)$/i;
2538
+ // Pass 1: find the last step with a meaningful expected VALUE
2539
+ for (let i = steps.length - 1; i >= 0; i--) {
2540
+ const exp = steps[i].expected;
2541
+ if (exp && exp.length > 4 && exp.length < 80 && !TRIVIAL_EXPECTED.test(exp))
2542
+ return exp;
2543
+ }
2544
+ // Pass 2: fall back to action-name extraction — require 5+ chars to exclude CRM abbreviations (Oppt, Div, Td)
2545
+ for (let i = steps.length - 1; i >= 0; i--) {
2546
+ const a = steps[i].action;
2547
+ const m = a.match(/^Verify\s+([A-Za-z]{5,})\s+(?:text|value|field):/i);
2548
+ if (m && !['Span', 'Label', 'Brand', 'Field', 'Input'].includes(m[1]))
2549
+ return m[1];
2550
+ }
2551
+ return '';
2552
+ })();
2553
+ // Meaningful menu items = non-generic (exclude UI state labels like "Active", "All", "Home")
2554
+ const meaningfulMenuItems = menuItems.filter(m => !GENERIC_MENU_ITEMS.has(m.toLowerCase()));
2555
+ if (meaningfulMenuItems.length >= 2) {
2556
+ const primary = meaningfulMenuItems[meaningfulMenuItems.length - 1];
2557
+ const outcome = lastAssertEl ? `Verify ${lastAssertEl}` : `Verify Results`;
2558
+ title = `${primary}: ${outcome}`;
2559
+ }
2560
+ else if (meaningfulMenuItems.length === 1) {
2561
+ title = lastAssertEl ? `${meaningfulMenuItems[0]}: Verify ${lastAssertEl}` : `Verify ${meaningfulMenuItems[0]}`;
2562
+ }
2563
+ else if (lastAssertEl) {
2564
+ title = `Verify ${lastAssertEl}`;
2565
+ }
2566
+ else {
2567
+ // Priority 2: URL title= query params or path segments
2568
+ try {
2569
+ const u = new URL(firstUrl);
2570
+ const page = u.pathname.split('/').filter(Boolean).pop()?.replace(/\.[^.]+$/, '') ?? '';
2571
+ const isMultiPage = realUrls.length > 2 || steps.length > 15;
2572
+ if (isMultiPage) {
2573
+ const titleParamLabels = [];
2574
+ const pathSegLabels = [];
2575
+ for (const raw of realUrls) {
2576
+ try {
2577
+ const pu = new URL(raw);
2578
+ const tp = pu.searchParams.get('title');
2579
+ if (tp) {
2580
+ const label = tp.replace(/\+/g, ' ').replace(/%20/g, ' ').trim();
2581
+ if (label)
2582
+ titleParamLabels.push(label);
2583
+ }
2584
+ else {
2585
+ const seg = pu.pathname.split('/').filter(Boolean).pop()?.replace(/\.[^.]+$/, '') ?? '';
2586
+ // Skip generic segments, pure numbers, all-lowercase compound words, and technical paths
2587
+ const isCompoundLower = /^[a-z]{10,}$/.test(seg); // all-lowercase = unreadable compound word
2588
+ if (seg && !GENERIC_SEGS.has(seg.toLowerCase()) && !/^\d+$/.test(seg) && !seg.includes('::') && seg.length < 40 && !isCompoundLower) {
2589
+ pathSegLabels.push(fzeHumanize(seg));
2590
+ }
2591
+ }
2592
+ }
2593
+ catch { /* skip malformed URLs */ }
2594
+ }
2595
+ const sourceLabels = titleParamLabels.length > 0 ? titleParamLabels : pathSegLabels;
2596
+ const uniqueLabels = [...new Set(sourceLabels)].slice(0, 3);
2597
+ title = uniqueLabels.length > 1
2598
+ ? `${uniqueLabels[0]}: Verify ${uniqueLabels[uniqueLabels.length - 1]}`
2599
+ : uniqueLabels.length === 1
2600
+ ? `Verify ${uniqueLabels[0]} End-to-End`
2601
+ : fzeHumanize(page) || `${u.hostname} End-to-End Test`;
2602
+ }
2603
+ else {
2604
+ title = page ? fzeHumanize(page) : u.hostname;
2605
+ }
2606
+ }
2607
+ catch { /* use default */ }
2608
+ }
2609
+ // Preconditions from credentials + start URL
2610
+ const preconditionParts = [
2611
+ credentials.username ? `Login as: ${credentials.username}` : null,
2612
+ firstUrl ? `Start URL: ${firstUrl}` : null,
2613
+ ].filter(Boolean);
2614
+ // Build Gherkin scenario
2615
+ let hasGiven = false, hasWhen = false, hasThen = false;
2616
+ const gherkinLines = [`Scenario: ${title}`];
2617
+ for (const step of steps) {
2618
+ const isNav = step.action.startsWith('Navigate to:');
2619
+ const isAssert = step.expected !== '';
2620
+ const isMeta = step.action.startsWith('[Conditional') || step.action.startsWith('Switch');
2621
+ let kw;
2622
+ if (isAssert) {
2623
+ kw = hasThen ? 'And' : 'Then';
2624
+ hasThen = true;
2625
+ }
2626
+ else if ((isNav || isMeta) && !hasWhen) {
2627
+ kw = hasGiven ? 'And' : 'Given';
2628
+ hasGiven = true;
2629
+ }
2630
+ else {
2631
+ kw = hasWhen ? 'And' : 'When';
2632
+ hasWhen = true;
2633
+ }
2634
+ gherkinLines.push(` ${kw} ${step.action}`);
2635
+ }
2636
+ if (!hasThen)
2637
+ gherkinLines.push(' Then all steps complete without error');
2638
+ // Assemble testDataInline: form data + scripts + navigation URLs + metadata
2639
+ const testDataInline = {};
2640
+ if (Object.keys(formData).length > 0)
2641
+ Object.assign(testDataInline, formData);
2642
+ if (customScripts.length > 0)
2643
+ testDataInline.customScripts = customScripts;
2644
+ // Store credential env-var markers so script generation can emit process.env.X refs.
2645
+ if (credentials.username) {
2646
+ const envM = credentials.username.match(/^\{\{\s*fze\.env\.([^}\s]+?)\s*\}\}$/i);
2647
+ if (envM) {
2648
+ if (!testDataInline.username)
2649
+ testDataInline.username = `[env:${envM[1]}]`;
2650
+ }
2651
+ else if (!fzeIsDynamic(credentials.username)) {
2652
+ testDataInline.recordedUser = credentials.username;
2653
+ }
2654
+ }
2655
+ if (credentials.password)
2656
+ testDataInline.recordedPassword = '[env:TEST_USER_PASSWORD]'; // Never store raw credentials in test data — use env var reference
2657
+ if (selectionValues.length > 0)
2658
+ testDataInline.recordedSelections = selectionValues;
2659
+ if (urls.length > 0) {
2660
+ testDataInline.recordedUrls = urls;
2661
+ // Also store first URL as startUrl so buildTestDataBodyFromInline emits it as a named field.
2662
+ if (!testDataInline.startUrl && urls[0]) {
2663
+ const envM = urls[0].match(/^\{\{\s*fze\.env\.([^}\s]+?)\s*\}\}$/i);
2664
+ testDataInline.startUrl = envM ? `[env:${envM[1]}]` : urls[0];
2665
+ }
2666
+ }
2667
+ if (contextSwitchCount > 0)
2668
+ testDataInline.contextSwitches = contextSwitchCount;
2669
+ // Infer test kind from content
2670
+ const hasLogin = allKeys.includes('user') || allKeys.includes('loginbtn');
2671
+ const kind = (urls.length > 4 || steps.length > 12)
2672
+ ? 'E2E'
2673
+ : hasLogin ? 'INTEGRATION' : 'POSITIVE';
2674
+ const priority = customScripts.length > 2 ? 'HIGH' : 'MEDIUM';
2675
+ const allStepsStr = steps.map(s => s.action).join('|');
2676
+ const fzeRefHash = node_crypto_1.default.createHash('sha256').update(allStepsStr + firstUrl).digest('hex').slice(0, 8).toUpperCase();
2677
+ cases.push({
2678
+ ref: explicitRef ? normalizeRef(explicitRef, sourcePlatform ?? 'Functionize') : `${platformPrefix(sourcePlatform ?? 'Functionize')}${fzeRefHash}`,
2679
+ title,
2680
+ kind,
2681
+ priority,
2682
+ preconditions: preconditionParts.length > 0 ? preconditionParts.join('; ') : undefined,
2683
+ steps,
2684
+ expectedResult: lastAssertion
2685
+ ? `Verify: "${lastAssertion}"`
2686
+ : 'All recorded steps complete without error',
2687
+ gherkin: gherkinLines.join('\n'),
2688
+ tags: ['functionize', 'imported'],
2689
+ ...(Object.keys(testDataInline).length > 0 ? { testDataInline } : {}),
2690
+ });
2691
+ }
2692
+ return { cases, errors };
2693
+ }
2694
+ // ─── JSON direct parser ──────────────────────────────────────────────────────
2695
+ function tryParseJsonCases(content, sourcePlatform) {
2696
+ const trimmed = content.trim();
2697
+ if (!trimmed.startsWith('[') && !trimmed.startsWith('{')) {
2698
+ // Try extracting JSON from markdown code fence
2699
+ const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
2700
+ if (fenceMatch)
2701
+ return tryParseJsonCases(fenceMatch[1].trim());
2702
+ // Also handle markdown heading prefix (e.g. "# filename.json\n\n[...") from JSON ingestion
2703
+ if (trimmed.startsWith('#')) {
2704
+ const arrayIdx = trimmed.indexOf('\n[');
2705
+ const objIdx = trimmed.indexOf('\n{');
2706
+ const jsonIdx = arrayIdx !== -1 && objIdx !== -1 ? Math.min(arrayIdx, objIdx)
2707
+ : arrayIdx !== -1 ? arrayIdx : objIdx;
2708
+ if (jsonIdx !== -1)
2709
+ return tryParseJsonCases(trimmed.slice(jsonIdx + 1));
2710
+ }
2711
+ return null;
2712
+ }
2713
+ // Pass 1: standard parse
2714
+ let data;
2715
+ let parseOk = false;
2716
+ try {
2717
+ data = JSON.parse(trimmed);
2718
+ parseOk = true;
2719
+ }
2720
+ catch { /* try repair */ }
2721
+ // Pass 2: state-machine repair (escape literal newlines/carriage-returns inside strings)
2722
+ if (!parseOk) {
2723
+ try {
2724
+ data = JSON.parse(repairJsonStringLiterals(trimmed));
2725
+ parseOk = true;
2726
+ }
2727
+ catch { /* try raw extractor */ }
2728
+ }
2729
+ // Pass 3: line-based raw pair extractor (handles unescaped double-quotes in JS code values)
2730
+ if (!parseOk) {
2731
+ const rawPairs = extractFzeRawPairs(trimmed);
2732
+ if (rawPairs.length > 0 && isFunctionizeFormat(rawPairs)) {
2733
+ return parseFunctionizeCases(rawPairs);
2734
+ }
2735
+ // Pass 3b: regex-based FZE probe — catches compact/unusual formatting where
2736
+ // extractFzeRawPairs still returns []. If FZE markers are present in the raw
2737
+ // text, try bracketing the content and re-parsing with repaired literals.
2738
+ const hasFzeMarkers = /['"](pageinit|loginbtn|customcode|contextswitch|sz[A-Z])['"]\s*:/.test(trimmed);
2739
+ if (hasFzeMarkers) {
2740
+ // Strip outer array wrapper and split on object boundaries to handle
2741
+ // "[{...},{...}]" patterns without relying on line-based structure.
2742
+ const inner = trimmed.replace(/^\s*\[\s*/, '').replace(/\s*\]\s*$/, '');
2743
+ // Re-run the raw extractor on the unwrapped content prefixed with "{"
2744
+ // so it sees a clean object start even if "[{" was on one line.
2745
+ const unwrappedPairs = extractFzeRawPairs(`[\n${inner}\n]`);
2746
+ if (unwrappedPairs.length > 0 && isFunctionizeFormat(unwrappedPairs)) {
2747
+ return parseFunctionizeCases(unwrappedPairs);
2748
+ }
2749
+ }
2750
+ return null;
2751
+ }
2752
+ // Normalize to array
2753
+ const arr = Array.isArray(data) ? data
2754
+ : data?.testCases ?? data?.cases ?? data?.tests ?? data?.TestCases ?? null;
2755
+ if (!Array.isArray(arr) || arr.length === 0)
2756
+ return null;
2757
+ // Functionize recording format
2758
+ if (isFunctionizeFormat(arr))
2759
+ return parseFunctionizeCases(arr, sourcePlatform);
2760
+ // Check that items look like test cases
2761
+ const first = arr[0];
2762
+ const titleVal = first?.title ?? first?.name ?? first?.summary ?? first?.testName ?? first?.test_name ?? first?.TestCaseName ?? first?.['TC Description'];
2763
+ if (!titleVal)
2764
+ return null;
2765
+ const cases = [];
2766
+ for (const item of arr) {
2767
+ const titleRaw = item.title ?? item.name ?? item.summary ?? item.testName ?? item.test_name ?? item.TestCaseName ?? item['TC Description'] ?? '';
2768
+ if (!titleRaw)
2769
+ continue;
2770
+ const rawSteps = item.steps ?? item.testSteps ?? item.test_steps ?? item.Steps ?? item.stepsList ?? item.actions ?? item.teststeps ?? [];
2771
+ const steps = [];
2772
+ if (Array.isArray(rawSteps)) {
2773
+ for (const s of rawSteps) {
2774
+ if (typeof s === 'string') {
2775
+ steps.push({ action: s, expected: '' });
2776
+ }
2777
+ else if (s) {
2778
+ // Check for XPath locator fields and convert to semantic selectors
2779
+ const rawLocator = s.locator ?? s.selector ?? s.xpath ?? s.target ?? undefined;
2780
+ let comment;
2781
+ if (rawLocator && typeof rawLocator === 'string') {
2782
+ const converted = tryConvertXPathToSemantic(rawLocator);
2783
+ if (converted) {
2784
+ comment = `locatorHint: ${converted}`;
2785
+ }
2786
+ else {
2787
+ comment = `xpathFallback: ${rawLocator}`;
2788
+ }
2789
+ }
2790
+ steps.push({
2791
+ action: String(s.action ?? s.step ?? s.description ?? s.Action ??
2792
+ s.keyword ?? s.command ?? s.instruction ?? s.label ??
2793
+ s.name ?? s.stepDescription ?? s.step_description ?? ''),
2794
+ data: s.data ?? s.testData ?? s.test_data ?? undefined,
2795
+ expected: String(s.expected ?? s.expectedResult ?? s.expected_result ?? s.Expected ??
2796
+ s.verification ?? s.assertion ?? s.verify ?? s.outcome ?? ''),
2797
+ ...(comment ? { comment } : {}),
2798
+ });
2799
+ }
2800
+ }
2801
+ }
2802
+ if (steps.length === 0) {
2803
+ const exp = String(item.expectedResult ?? item.expected_result ?? item.expected ?? item.ExpectedResult ?? titleRaw);
2804
+ steps.push({ action: 'Execute test case', expected: exp });
2805
+ }
2806
+ const tc = sanitizeParsedCase({
2807
+ ref: (() => {
2808
+ const raw = item.id ?? item.ref ?? item.tcId ?? item.tc_id ?? item['TC_Id #'];
2809
+ if (!raw)
2810
+ return undefined;
2811
+ return normalizeRef(String(raw), sourcePlatform);
2812
+ })(),
2813
+ title: String(titleRaw),
2814
+ kind: item.type ?? item.kind ?? item.testType ?? item.test_type ?? item.Type ?? 'POSITIVE',
2815
+ priority: item.priority ?? item.Priority ?? 'MEDIUM',
2816
+ preconditions: item.preconditions ?? item.prerequisites ?? item.Preconditions ?? undefined,
2817
+ steps,
2818
+ expectedResult: String(item.expectedResult ?? item.expected_result ?? item.expected ?? item.ExpectedResult ?? titleRaw),
2819
+ tags: Array.isArray(item.tags ?? item.labels) ? (item.tags ?? item.labels) : undefined,
2820
+ section: item.section ?? item.module ?? item.suite ?? item.folder ?? item.component ?? item['Module/Suite'] ?? undefined,
2821
+ testDataInline: parseImportedTestData(item.testData ?? item.test_data ?? item.data ?? item.inputData ?? item.input_data ??
2822
+ item.inputs ?? item.parameters ?? item.params ?? item.variables ?? item.dataset ?? item.dataSet),
2823
+ });
2824
+ if (tc)
2825
+ cases.push(tc);
2826
+ }
2827
+ if (cases.length === 0)
2828
+ return null;
2829
+ return { cases, errors: [] };
2830
+ }
2831
+ const EDITORIAL_NOTE_RE = /\b(delete this section|keep .* section here|please delete|draft note|todo|tbd)\b/i;
2832
+ const REQ_ID_RE = /^(FR|NFR)\d{3}$/i;
2833
+ function cleanDocLine(line) {
2834
+ return line.replace(/\f/g, '').replace(/\u200f/g, '').trim();
2835
+ }
2836
+ function isLikelyHeading(line) {
2837
+ return /^\d+(?:\.\d+)*\s+[A-Z][A-Za-z0-9 &/(),-]+$/.test(line)
2838
+ || /^(Functional Requirements|Non-Functional Requirements|Business Rules|Exception Scenarios)$/i.test(line);
2839
+ }
2840
+ function normalizeScenarioTitle(raw, max = 78) {
2841
+ const cleaned = raw.replace(/\s+/g, ' ').trim();
2842
+ return cleaned.length <= max ? cleaned : `${cleaned.slice(0, max - 1).trim()}…`;
2843
+ }
2844
+ function scenarioCategory(text, fallback) {
2845
+ const lower = text.toLowerCase();
2846
+ if (/security|role|permission|access|login|credential|agreement|audit/.test(lower))
2847
+ return 'security-access';
2848
+ if (/batch|overnight|backup|purge|retention|deadline/.test(lower))
2849
+ return 'batch-recovery';
2850
+ if (/dmv|host|connection|download|query|restore|real[- ]time/.test(lower))
2851
+ return 'integration';
2852
+ if (/inventory|plate|sticker|decal|purchase order|reconciliation/.test(lower))
2853
+ return 'inventory';
2854
+ if (/print|document|certificate|tag|bundle report/.test(lower))
2855
+ return 'documents-printing';
2856
+ if (/vin|expiry|expiration|odometer|exemption|fee|tax|validation|block|prevent|reject|required/.test(lower))
2857
+ return 'validation';
2858
+ if (/available|seconds|performance|local functionality|temporarily unavailable/.test(lower))
2859
+ return 'nonfunctional';
2860
+ if (/registration|title|transaction|renewal|backout|fee inquiry|temporary tag|transport tag|dms/.test(lower))
2861
+ return 'transactions';
2862
+ return fallback;
2863
+ }
2864
+ function suggestedCountFor(text, id) {
2865
+ const lower = text.toLowerCase();
2866
+ if (/end-to-end|full|compliance|real[- ]time|external|dmv|workflow/.test(lower))
2867
+ return 8;
2868
+ if (/transaction|registration|inventory|batch|reconciliation|restore|recovery|2-step|pending/.test(lower))
2869
+ return 6;
2870
+ if (/prevent|block|reject|invalid|boundary|required|role|access|permission/.test(lower))
2871
+ return 4;
2872
+ if (id?.startsWith('NFR'))
2873
+ return 3;
2874
+ return 3;
2875
+ }
2876
+ function excerptAround(content, re) {
2877
+ const flags = re.flags.includes('i') ? re.flags : `${re.flags}i`;
2878
+ const match = new RegExp(re.source, flags).exec(content);
2879
+ if (!match)
2880
+ return undefined;
2881
+ const start = Math.max(0, content.lastIndexOf('\n', Math.max(0, match.index - 220)));
2882
+ const endRaw = content.indexOf('\n', match.index + match[0].length + 220);
2883
+ const end = endRaw > start ? endRaw : Math.min(content.length, match.index + match[0].length + 220);
2884
+ return content.slice(start, end).split(/\r?\n/).map(cleanDocLine).filter(Boolean).join(' ').slice(0, 500);
2885
+ }
2886
+ function makeGroundedScenario(args) {
2887
+ const category = args.category ?? scenarioCategory(args.statement, args.id.startsWith('NFR') ? 'nonfunctional' : 'functional');
2888
+ return {
2889
+ id: `src-${args.id.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
2890
+ requirementId: args.id,
2891
+ title: normalizeScenarioTitle(`${args.id} ${args.statement.replace(/^The system shall\s+/i, '')}`),
2892
+ description: `Verify source requirement ${args.id}: ${args.statement}`,
2893
+ sourceSection: args.sourceSection,
2894
+ category,
2895
+ sourceExcerpt: args.sourceExcerpt ?? args.statement,
2896
+ suggestedTestCount: suggestedCountFor(args.statement, args.id),
2897
+ };
2898
+ }
2899
+ const BUSINESS_RULE_PATTERNS = [
2900
+ { id: 'BR-VIN-FORMAT', title: 'VIN format excludes invalid characters by vehicle type', sourceSection: 'Vehicle Data Rules', re: /VIN[\s\S]{0,220}(17|alpha)[\s\S]{0,220}(O,\s*I,\s*U,\s*Q|except letters)/i, category: 'validation', count: 5 },
2901
+ { id: 'BR-MOPED-EXEMPTION', title: 'Moped invalid exemption blocks transmission', sourceSection: 'Tax Exemption Rules', re: /Moped transaction with an exemption type[^.\n]*blocked from transmission/i, category: 'validation', count: 4 },
2902
+ { id: 'BR-TEMP-TAG-ROLES', title: 'Temporary tag roles govern create, reissue, reprint, void, amend, and CVR access', sourceSection: 'Temporary Tag Permission Roles', re: /operator without the Original or Allow CVR role cannot create new registration records/i, category: 'security-access', count: 6 },
2903
+ { id: 'BR-RENEWAL-ELIGIBILITY', title: 'Standard renewal accepts current plate expiration within 1 to 24 months', sourceSection: 'Registration Renewal Rules', re: /current plate expiration date must be within 1 to 24 months/i, category: 'validation', count: 4 },
2904
+ { id: 'BR-RENEWAL-FUTURE-LIMIT', title: 'Future expiry dates beyond 63 months are rejected', sourceSection: 'Registration Renewal Rules', re: /expiry date of more than 63 months/i, category: 'validation', count: 3 },
2905
+ { id: 'BR-PERMANENT-TRAILER-EXPIRY', title: 'Permanent trailer plate series hide renewal expiry fields', sourceSection: 'Registration Renewal Rules', re: /No expiry date applies to series TRLPM and SMTRL/i, category: 'validation', count: 3 },
2906
+ { id: 'BR-RENTAL-TNC', title: 'Rental-TNC registrations lock vehicle use and require TNC date', sourceSection: 'Dealer Type and Vehicle Class Assignment Rules', re: /Rental-TNC registration requires a TNC date/i, category: 'validation', count: 4 },
2907
+ { id: 'BR-MULTI-USE-CUTOFF', title: 'Multi-Use plate inventory ordering is blocked after cutoff', sourceSection: 'Inventory Management Rules', re: /Multi-Use plate ordering cutoff|block ordering of Multi-Use/i, category: 'inventory', count: 3 },
2908
+ { id: 'BR-INVENTORY-BACKOUT', title: 'Backout releases consumed inventory and restores expected status', sourceSection: 'Inventory Management Rules', re: /Backout releases inventory|Inventory status on backout/i, category: 'inventory', count: 4 },
2909
+ { id: 'BR-BACKOUT-REASON', title: 'Backout reason code is mandatory', sourceSection: 'Transaction Lifecycle Rules', re: /Backout reason code is mandatory/i, category: 'validation', count: 3 },
2910
+ { id: 'BR-FI-RETRANSMIT', title: 'F&I station retransmits only pending Temporary Tag or Transport Tag records', sourceSection: 'Transaction Lifecycle Rules', re: /F&I station can only retransmit a pending 2-step record/i, category: 'security-access', count: 4 },
2911
+ { id: 'BR-OVERNIGHT-SEQUENCE', title: 'Overnight batch follows fixed ordered steps and stops on failure', sourceSection: 'Data Integrity Rules', re: /overnight batch runs in this fixed order/i, category: 'batch-recovery', count: 6 },
2912
+ { id: 'BR-OVERNIGHT-RECOVERY-RETRIES', title: 'Overnight recovery retries missing DMV transactions three times', sourceSection: 'Transaction Lifecycle Rules', re: /retrieve each missing transaction from the Virginia DMV up to 3 times/i, category: 'batch-recovery', count: 4 },
2913
+ { id: 'BR-OVERNIGHT-DEADLINE', title: 'Overnight batch deadline and randomized host-load window are enforced', sourceSection: 'Regulatory Compliance Rules', re: /batch completion window closes at 10:15 PM CST/i, category: 'batch-recovery', count: 4 },
2914
+ { id: 'BR-FEE-TABLES', title: 'Fee calculations use only DMV-downloaded fee tables', sourceSection: 'Fee Components', re: /fee calculations shall be based exclusively on Virginia DMV fee tables/i, category: 'integration', count: 4 },
2915
+ { id: 'BR-VSA-ACCEPTANCE', title: 'Virginia State Agreement must be accepted before access', sourceSection: 'Regulatory Compliance Rules', re: /Every operator must accept the Virginia State Agreement/i, category: 'security-access', count: 3 },
2916
+ { id: 'BR-VSA-NAME-LENGTH', title: 'Virginia State Agreement operator name requires at least five characters', sourceSection: 'Regulatory Compliance Rules', re: /name entered on the agreement acceptance screen must be at least 5 characters/i, category: 'validation', count: 3 },
2917
+ { id: 'BR-TEMP-TAG-OWNER-SEX', title: 'Temporary Tag owner sex is required for all owners', sourceSection: 'Ownership and Title Rules', re: /Owner sex is required for Temporary Tag transactions/i, category: 'validation', count: 3 },
2918
+ ];
2919
+ function extractGroundedScenarioCandidates(content) {
2920
+ const lines = content.split(/\r?\n/).map(cleanDocLine);
2921
+ const scenarios = [];
2922
+ let sourceSection = '';
2923
+ for (let i = 0; i < lines.length; i++) {
2924
+ const line = lines[i];
2925
+ if (!line || EDITORIAL_NOTE_RE.test(line))
2926
+ continue;
2927
+ if (isLikelyHeading(line))
2928
+ sourceSection = line;
2929
+ if (!REQ_ID_RE.test(line))
2930
+ continue;
2931
+ const id = line.toUpperCase();
2932
+ const statementParts = [];
2933
+ for (let j = i + 1; j < Math.min(lines.length, i + 8); j++) {
2934
+ const next = lines[j];
2935
+ if (!next || REQ_ID_RE.test(next) || isLikelyHeading(next))
2936
+ break;
2937
+ if (/^(Req #|Requirement|Rule|Constraint)$/i.test(next) || EDITORIAL_NOTE_RE.test(next))
2938
+ continue;
2939
+ statementParts.push(next);
2940
+ if (/[.!?]$/.test(next) || statementParts.join(' ').length > 220)
2941
+ break;
2942
+ }
2943
+ const statement = statementParts.join(' ').replace(/\s+/g, ' ').trim();
2944
+ if (statement.length < 12)
2945
+ continue;
2946
+ scenarios.push(makeGroundedScenario({
2947
+ id,
2948
+ statement,
2949
+ sourceSection,
2950
+ sourceExcerpt: `${id}: ${statement}`,
2951
+ }));
2952
+ }
2953
+ for (const rule of BUSINESS_RULE_PATTERNS) {
2954
+ const sourceExcerpt = excerptAround(content, rule.re);
2955
+ if (!sourceExcerpt || EDITORIAL_NOTE_RE.test(sourceExcerpt))
2956
+ continue;
2957
+ scenarios.push({
2958
+ id: `src-${rule.id.toLowerCase()}`,
2959
+ requirementId: rule.id,
2960
+ title: normalizeScenarioTitle(`${rule.id} ${rule.title}`),
2961
+ description: `Verify source business rule ${rule.id}: ${rule.title}`,
2962
+ sourceSection: rule.sourceSection,
2963
+ category: rule.category ?? scenarioCategory(rule.title, 'business-rule'),
2964
+ sourceExcerpt,
2965
+ suggestedTestCount: rule.count ?? suggestedCountFor(rule.title, rule.id),
2966
+ });
2967
+ }
2968
+ const byKey = new Map();
2969
+ for (const s of scenarios) {
2970
+ const key = s.requirementId ?? s.title.toLowerCase().replace(/[^a-z0-9]+/g, '');
2971
+ if (!byKey.has(key))
2972
+ byKey.set(key, s);
2973
+ }
2974
+ return [...byKey.values()];
2975
+ }
2976
+ async function extractScenarios(content, tenantId) {
2977
+ const llmCfg = await (0, llm_config_js_1.getTaskAgentLLMConfig)(tenantId, 'appBrain').catch(() => null);
2978
+ if (!llmCfg)
2979
+ throw new Error('No LLM provider configured. Please set up an AI model in Workspace → Settings → AI Model.');
2980
+ const SCENARIOS_DEFAULT = `You are a senior QA analyst. Extract ALL testable scenarios from the document.
2981
+ A "scenario" is a high-level feature, user story, requirement, or acceptance criterion that needs to be tested.
2982
+ Do NOT generate individual test steps — identify what needs testing at a feature/behavior level.
2983
+
2984
+ Rules:
2985
+ - Each scenario represents one coherent, non-overlapping feature or behavior
2986
+ - "title" should be short and descriptive (under 80 chars)
2987
+ - "description" explains what to verify (1-3 sentences)
2988
+ - "sourceSection" is the heading, section title, or slide number where found (omit if not identifiable)
2989
+ - "requirementId" is the explicit source ID when present (FR001, NFR002, or a named business rule)
2990
+ - "sourceExcerpt" is the short exact source text that justifies the scenario
2991
+ - "category" classifies the scenario, e.g. transactions, inventory, validation, integration, security-access, batch-recovery, nonfunctional
2992
+ - "suggestedTestCount" is your estimate of how many test cases this warrants (1-20); use higher counts for complex transactional flows
2993
+ - Return as many scenarios as the document warrants — be exhaustive, not selective
2994
+
2995
+ CRITICAL — you MUST cover ALL of these categories if present in the document:
2996
+ 1. Happy-path functional flows (each major feature gets its own scenario)
2997
+ 2. Error handling and failure recovery (network drop, host timeout, mid-transaction failure, printer offline)
2998
+ 3. Data validation (format checks, boundary values, invalid input rejection)
2999
+ 4. Security and access control (unauthorized access, privilege escalation, session management)
3000
+ 5. Non-functional requirements (performance thresholds, availability SLAs, batch processing)
3001
+ 6. Integration points (external system connections, real-time data exchange, error response handling)
3002
+ 7. Compliance and audit (audit trails, regulatory requirements, mandatory logging)
3003
+ 8. Edge cases per feature (concurrent users, special categories, exemptions, overrides)
3004
+
3005
+ DEDUPLICATION — do NOT create overlapping scenarios:
3006
+ - If a document has both a high-level summary section AND a detailed requirements section covering the same feature, produce ONE scenario that references both, not two separate scenarios
3007
+ - Merge scenarios that differ only in section number or heading label but test the same behavior
3008
+ - Never produce two scenarios with >50% overlap in what they test
3009
+
3010
+ suggestedTestCount guidance by complexity:
3011
+ - Simple CRUD or status check: 3-5 tests
3012
+ - Transaction with validation: 5-8 tests
3013
+ - Multi-step workflow with error paths: 8-12 tests
3014
+ - Complex integration with external system: 10-15 tests
3015
+ - Full end-to-end flow with compliance requirements: 12-20 tests`;
3016
+ const system = await (0, core_1.getAgentPrompt)('test-import-scenarios', undefined, tenantId) ?? SCENARIOS_DEFAULT;
3017
+ const schema = {
3018
+ type: 'object',
3019
+ properties: {
3020
+ scenarios: {
3021
+ type: 'array',
3022
+ items: {
3023
+ type: 'object',
3024
+ properties: {
3025
+ title: { type: 'string' },
3026
+ description: { type: 'string' },
3027
+ sourceSection: { type: 'string' },
3028
+ requirementId: { type: 'string' },
3029
+ category: { type: 'string' },
3030
+ sourceExcerpt: { type: 'string' },
3031
+ suggestedTestCount: { type: 'number' },
3032
+ },
3033
+ required: ['title', 'description', 'suggestedTestCount'],
3034
+ },
3035
+ },
3036
+ },
3037
+ required: ['scenarios'],
3038
+ };
3039
+ const truncated = content.length > 48000 ? content.slice(0, 48000) : content;
3040
+ const user = `Extract ALL testable scenarios from this document. Be exhaustive — cover functional flows, error paths, validation, security, non-functional requirements, and compliance:\n\n${truncated}`;
3041
+ const groundedScenarios = extractGroundedScenarioCandidates(content);
3042
+ try {
3043
+ const seed = (0, llm_js_1.deterministicSeed)(`extract-scenarios:${content.slice(0, 2000)}`);
3044
+ const { text } = await (0, llm_js_1.askTracked)(system, user, llmCfg, 8192, schema, { temperature: 0, seed });
3045
+ const parsed = JSON.parse(text);
3046
+ const rawScenarios = parsed.scenarios ?? [];
3047
+ // Dedup by normalized title — remove scenarios whose title is >70% similar to an earlier one
3048
+ const seen = [];
3049
+ const isRedundant = (title) => {
3050
+ const normalized = title.toLowerCase().replace(/[^a-z0-9 ]/g, '').trim();
3051
+ const words = new Set(normalized.split(/\s+/).filter((w) => w.length > 3));
3052
+ for (const s of seen) {
3053
+ const sWords = new Set(s.split(/\s+/).filter((w) => w.length > 3));
3054
+ const intersection = [...words].filter((w) => sWords.has(w)).length;
3055
+ const union = new Set([...words, ...sWords]).size;
3056
+ if (union > 0 && intersection / union >= 0.7)
3057
+ return true;
3058
+ }
3059
+ return false;
3060
+ };
3061
+ const aiScenarios = rawScenarios
3062
+ .map((s, i) => ({
3063
+ id: `sc-${i}`,
3064
+ title: String(s.title ?? '').trim(),
3065
+ description: String(s.description ?? '').trim(),
3066
+ sourceSection: s.sourceSection ? String(s.sourceSection).trim() : undefined,
3067
+ requirementId: s.requirementId ? String(s.requirementId).trim().toUpperCase() : undefined,
3068
+ category: s.category ? String(s.category).trim() : undefined,
3069
+ sourceExcerpt: s.sourceExcerpt ? String(s.sourceExcerpt).trim().slice(0, 500) : undefined,
3070
+ suggestedTestCount: Math.max(1, Math.min(20, Number(s.suggestedTestCount) || 3)),
3071
+ }))
3072
+ .filter((s) => s.title)
3073
+ .filter((s) => {
3074
+ if (s.requirementId)
3075
+ return true;
3076
+ const normalized = s.title.toLowerCase().replace(/[^a-z0-9 ]/g, '').trim();
3077
+ if (isRedundant(normalized))
3078
+ return false;
3079
+ seen.push(normalized);
3080
+ return true;
3081
+ });
3082
+ const scenarios = [];
3083
+ const coveredReqIds = new Set();
3084
+ const seenTitles = new Set();
3085
+ for (const s of [...groundedScenarios, ...aiScenarios]) {
3086
+ const reqId = s.requirementId?.trim();
3087
+ if (reqId) {
3088
+ if (coveredReqIds.has(reqId))
3089
+ continue;
3090
+ coveredReqIds.add(reqId);
3091
+ }
3092
+ else {
3093
+ const titleKey = s.title.toLowerCase().replace(/[^a-z0-9]+/g, '');
3094
+ if (seenTitles.has(titleKey))
3095
+ continue;
3096
+ seenTitles.add(titleKey);
3097
+ }
3098
+ scenarios.push(s);
3099
+ }
3100
+ return { scenarios, errors: [] };
3101
+ }
3102
+ catch (err) {
3103
+ if (groundedScenarios.length > 0) {
3104
+ return {
3105
+ scenarios: groundedScenarios,
3106
+ errors: [`AI scenario extraction failed; used deterministic BRD coverage instead: ${err?.message ?? String(err)}`],
3107
+ };
3108
+ }
3109
+ throw new Error(`Scenario extraction failed: ${err?.message ?? String(err)}`);
3110
+ }
3111
+ }
3112
+ // ─── Selenium locator injection ───────────────────────────────────────────────
3113
+ /**
3114
+ * Parse a Selenium Python script and extract a map of action descriptions → Playwright locators.
3115
+ * Used to annotate FZE test case steps with real locators from matched Selenium recordings.
3116
+ */
3117
+ function buildSeleniumHints(pyContent) {
3118
+ const hints = new Map();
3119
+ if (!pyContent)
3120
+ return hints;
3121
+ const lines = pyContent.split('\n');
3122
+ for (const line of lines) {
3123
+ const trimmed = line.trim();
3124
+ // Extract send_keys calls: driver.find_element(By.XPATH, "...").send_keys("value")
3125
+ const sendKeysMatch = trimmed.match(/find_element\s*\(\s*By\.XPATH\s*,\s*["']([^"']+)["']\s*\)\.send_keys\s*\(\s*["']?([^"')]+)["']?\s*\)/);
3126
+ if (sendKeysMatch) {
3127
+ const [, xpath, value] = sendKeysMatch;
3128
+ const locator = xpathToPlaywright(xpath);
3129
+ if (locator && value && value.length < 100) {
3130
+ hints.set(`type:${value.toLowerCase().trim().slice(0, 40)}`, locator);
3131
+ }
3132
+ continue;
3133
+ }
3134
+ // Extract click calls: driver.find_element(By.XPATH, "...").click()
3135
+ const clickMatch = trimmed.match(/find_element\s*\(\s*By\.XPATH\s*,\s*["']([^"']+)["']\s*\)\.click\s*\(\s*\)/);
3136
+ if (clickMatch) {
3137
+ const [, xpath] = clickMatch;
3138
+ const locator = xpathToPlaywright(xpath);
3139
+ if (locator) {
3140
+ const textMatch = xpath.match(/text\(\)\s*=\s*["']([^"']+)["']/);
3141
+ const ariaMatch = xpath.match(/@aria-label\s*=\s*["']([^"']+)["']/);
3142
+ const idMatch = xpath.match(/@id\s*=\s*["']([^"']+)["']/);
3143
+ const key = textMatch?.[1] || ariaMatch?.[1] || idMatch?.[1] || '';
3144
+ if (key)
3145
+ hints.set(`click:${key.toLowerCase().trim().slice(0, 40)}`, locator);
3146
+ }
3147
+ continue;
3148
+ }
3149
+ // Extract switch_to.frame calls
3150
+ const frameMatch = trimmed.match(/switch_to\.frame\s*\(\s*["']?([^"')]+)["']?\s*\)/);
3151
+ if (frameMatch) {
3152
+ const [, frame] = frameMatch;
3153
+ hints.set(`switch:frame:${frame.trim().slice(0, 40)}`, `iframe[name="${frame.trim()}"]`);
3154
+ }
3155
+ }
3156
+ return hints;
3157
+ }
3158
+ // Convert common XPath patterns to Playwright locators
3159
+ function xpathToPlaywright(xpath) {
3160
+ // aria-label
3161
+ const ariaMatch = xpath.match(/@aria-label\s*=\s*["']([^"']+)["']/);
3162
+ if (ariaMatch)
3163
+ return `[aria-label="${ariaMatch[1]}"]`;
3164
+ // text() contains or equals
3165
+ const textContains = xpath.match(/contains\s*\(\s*text\s*\(\s*\)\s*,\s*["']([^"']+)["']\s*\)/);
3166
+ if (textContains)
3167
+ return `text=${textContains[1]}`;
3168
+ const textEq = xpath.match(/text\(\)\s*=\s*["']([^"']+)["']/);
3169
+ if (textEq)
3170
+ return `text="${textEq[1]}"`;
3171
+ // id
3172
+ const idMatch = xpath.match(/@id\s*=\s*["']([^"']+)["']/);
3173
+ if (idMatch)
3174
+ return `#${idMatch[1]}`;
3175
+ // data-testid
3176
+ const testidMatch = xpath.match(/@data-testid\s*=\s*["']([^"']+)["']/);
3177
+ if (testidMatch)
3178
+ return `[data-testid="${testidMatch[1]}"]`;
3179
+ // placeholder
3180
+ const placeholderMatch = xpath.match(/@placeholder\s*=\s*["']([^"']+)["']/);
3181
+ if (placeholderMatch)
3182
+ return `[placeholder="${placeholderMatch[1]}"]`;
3183
+ // name attribute
3184
+ const nameMatch = xpath.match(/@name\s*=\s*["']([^"']+)["']/);
3185
+ if (nameMatch)
3186
+ return `[name="${nameMatch[1]}"]`;
3187
+ // Fallback to xpath locator (Playwright supports these)
3188
+ if (xpath.length < 200)
3189
+ return `xpath=${xpath}`;
3190
+ return null;
3191
+ }
3192
+ /**
3193
+ * Annotate parsed test case steps with Playwright locators from Selenium hints.
3194
+ * Matches steps by normalized action text against the hint map.
3195
+ * Adds locatorHint comment to matched steps.
3196
+ */
3197
+ function annotateWithSeleniumHints(cases, hints) {
3198
+ if (hints.size === 0)
3199
+ return cases;
3200
+ return cases.map(tc => ({
3201
+ ...tc,
3202
+ steps: tc.steps.map(step => {
3203
+ const actionLow = step.action.toLowerCase();
3204
+ // Try to match type steps: "Enter {field}: '{value}'"
3205
+ const enterMatch = step.action.match(/^Enter .+?:\s*['""]?(.+?)['""]?\s*$/i);
3206
+ if (enterMatch) {
3207
+ const value = enterMatch[1].replace(/^\{\{.+\}\}$/, '').trim().slice(0, 40).toLowerCase();
3208
+ const locator = value ? hints.get(`type:${value}`) : undefined;
3209
+ if (locator && !step.comment) {
3210
+ return { ...step, comment: `locatorHint: ${locator}` };
3211
+ }
3212
+ }
3213
+ // Try to match click steps: "Click {element}: '{text}'"
3214
+ const clickMatch = step.action.match(/^Click .+?:\s*['""]?(.+?)['""]?\s*$/i);
3215
+ if (clickMatch) {
3216
+ const label = clickMatch[1].trim().slice(0, 40).toLowerCase();
3217
+ const locator = hints.get(`click:${label}`);
3218
+ if (locator && !step.comment) {
3219
+ return { ...step, comment: `locatorHint: ${locator}` };
3220
+ }
3221
+ }
3222
+ // Match context switch steps
3223
+ if (actionLow.startsWith('switch to context')) {
3224
+ const frameMatch = step.action.match(/iframe\[name="([^"]+)"\]/);
3225
+ if (frameMatch) {
3226
+ const locator = hints.get(`switch:frame:${frameMatch[1].toLowerCase()}`);
3227
+ if (locator && !step.comment) {
3228
+ return { ...step, comment: `locatorHint: ${locator}` };
3229
+ }
3230
+ }
3231
+ }
3232
+ return step;
3233
+ }),
3234
+ }));
3235
+ }