@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,2284 @@
1
+ "use strict";
2
+ /**
3
+ * ZeTa-AI Test Data Generation Agent
4
+ *
5
+ * Field-schema grounded: loads typed AppField catalog before generating.
6
+ * If all grounded elements are non-data-bearing (buttons/links/toggles)
7
+ * → returns interaction variations, NOT fabricated field:value datasets.
8
+ *
9
+ * Four generation modes:
10
+ * FORM — ≥1 data-bearing field → typed, constraint-aware parameterized datasets
11
+ * STATE — all non-data-bearing → precondition-state datasets
12
+ * VOLUME — PERFORMANCE / LOAD kinds → data-setup specs (row counts, concurrency, seed)
13
+ * SKIP — kinds not suited to any parameterized data (EXPLORATORY, MUTATION, AB_TEST, etc.)
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.scheduleSkillFileRefresh = scheduleSkillFileRefresh;
17
+ exports.generateTestData = generateTestData;
18
+ exports.generateTestDataBatch = generateTestDataBatch;
19
+ exports.parseCsvToDatasets = parseCsvToDatasets;
20
+ exports.mergeExternalDatasets = mergeExternalDatasets;
21
+ const app_brain_1 = require("@taodigital/app-brain");
22
+ const llm_js_1 = require("./llm.js");
23
+ const llm_config_js_1 = require("./llm-config.js");
24
+ const core_1 = require("@taodigital/core");
25
+ const step_utils_js_1 = require("./step-utils.js");
26
+ const skill_file_agent_js_1 = require("./skill-file-agent.js");
27
+ // ─── PERF: Module-level TTL cache ────────────────────────────────────────────
28
+ // Skill files and LLM config rarely change. Cache them for 5 minutes to avoid
29
+ // a DB round-trip on every generateTestData call.
30
+ const CACHE_TTL_MS = 5 * 60 * 1000;
31
+ const _cache = new Map();
32
+ function _cacheGet(key) {
33
+ const e = _cache.get(key);
34
+ if (!e || Date.now() > e.expiresAt) {
35
+ _cache.delete(key);
36
+ return undefined;
37
+ }
38
+ return e.value;
39
+ }
40
+ function _cacheSet(key, value) {
41
+ _cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
42
+ }
43
+ async function _cachedLLMConfig(tenantId) {
44
+ const k = `llmcfg:${tenantId}`;
45
+ const hit = _cacheGet(k);
46
+ if (hit !== undefined)
47
+ return hit;
48
+ const val = await (0, llm_config_js_1.getTaskAgentLLMConfig)(tenantId, 'testGeneration');
49
+ if (val !== undefined)
50
+ _cacheSet(k, val);
51
+ return val;
52
+ }
53
+ async function _cachedSkillFile(tenantId, projectId, type) {
54
+ const k = `skill:${tenantId}:${projectId}:${type}`;
55
+ const hit = _cacheGet(k);
56
+ if (hit !== undefined)
57
+ return hit;
58
+ const val = await app_brain_1.AppBrain.getSkillFile(tenantId, projectId, type).catch(() => null);
59
+ _cacheSet(k, val);
60
+ return val;
61
+ }
62
+ // ─── PERF: Seed-keyed LLM response cache ─────────────────────────────────────
63
+ // L1: in-process LRU (1-hour TTL, 500-entry cap) — zero-latency hits on same pod.
64
+ // L2: Redis (1-hour TTL) — shared across all pods so only ONE pod pays the LLM
65
+ // cost for a given (tenant, project, testCase, kind, fieldCount) tuple.
66
+ //
67
+ // Cache key: deterministicSeed output (int32) stringified — content-derived,
68
+ // not time-based, so the same inputs always map to the same key.
69
+ // Redis is optional: if REDIS_URL is unset or Redis is down, the code falls back
70
+ // to L1 only. No correctness impact — LLM calls are temperature:0 + seeded so
71
+ // every pod independently generates identical data. Redis reduces redundant cost.
72
+ const _LLM_RESP_TTL = 60 * 60 * 1000;
73
+ const _LLM_RESP_TTL_S = 60 * 60; // Redis EXPIRE takes seconds
74
+ const _REDIS_KEY_PREFIX = 'zeta:data-agent:resp:';
75
+ const _llmRespCache = new Map();
76
+ function _respCacheGet(seed) {
77
+ const e = _llmRespCache.get(seed);
78
+ if (!e || Date.now() > e.at + _LLM_RESP_TTL) {
79
+ _llmRespCache.delete(seed);
80
+ return undefined;
81
+ }
82
+ return e.dataSets;
83
+ }
84
+ function _respCacheSet(seed, dataSets) {
85
+ if (_llmRespCache.size >= 500) {
86
+ const first = _llmRespCache.keys().next().value;
87
+ if (first !== undefined)
88
+ _llmRespCache.delete(first);
89
+ }
90
+ _llmRespCache.set(seed, { dataSets, at: Date.now() });
91
+ }
92
+ async function _respCacheGetL2(seed) {
93
+ const redis = (0, core_1.getRedisClient)();
94
+ if (!redis)
95
+ return undefined;
96
+ try {
97
+ const raw = await redis.get(`${_REDIS_KEY_PREFIX}${seed}`);
98
+ if (!raw)
99
+ return undefined;
100
+ return JSON.parse(raw);
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ async function _respCacheSetL2(seed, dataSets) {
107
+ const redis = (0, core_1.getRedisClient)();
108
+ if (!redis)
109
+ return;
110
+ try {
111
+ await redis.set(`${_REDIS_KEY_PREFIX}${seed}`, JSON.stringify(dataSets), 'EX', _LLM_RESP_TTL_S);
112
+ }
113
+ catch {
114
+ // Redis write failure is non-fatal — L1 still serves the same pod
115
+ }
116
+ }
117
+ // ─── PERF: Debounced skill-file refresh (30s window per project) ──────────────
118
+ // Prevents 25 concurrent refreshes when batch-generating 25 cases at once.
119
+ const _refreshTimers = new Map();
120
+ function scheduleSkillFileRefresh(tenantId, projectId, delayMs = 30_000) {
121
+ const k = `${tenantId}:${projectId}`;
122
+ const existing = _refreshTimers.get(k);
123
+ if (existing)
124
+ clearTimeout(existing);
125
+ const t = setTimeout(() => {
126
+ _refreshTimers.delete(k);
127
+ (0, skill_file_agent_js_1.refreshTestDataSkillFile)(tenantId, projectId).catch(console.error);
128
+ }, delayMs);
129
+ _refreshTimers.set(k, t);
130
+ }
131
+ // PERFORMANCE and LOAD now generate VOLUME mode datasets (data setup specs), not skipped.
132
+ const SKIP_KINDS = new Set(['EXPLORATORY', 'MUTATION', 'AB_TEST', 'ALPHA_BETA', 'SYSTEM']);
133
+ // Hard ceiling per test case — set high so the model generates as many as it finds meaningful.
134
+ // Override with DATA_AGENT_ROW_LIMIT env var.
135
+ const DATA_LIMIT = parseInt(process.env.DATA_AGENT_ROW_LIMIT ?? '50', 10);
136
+ // Minimum datasets guaranteed per test case (never fewer than this).
137
+ const DATA_MIN = 3;
138
+ /**
139
+ * Contextual maximum — returns a generous ceiling so the model generates
140
+ * all meaningful scenarios without an artificial cap.
141
+ */
142
+ function contextualMax(fields, kind, mode) {
143
+ if (mode === 'volume')
144
+ return 8; // baseline, peak, stress, spike, ramp, soak, burst, cold-start
145
+ if (mode === 'state')
146
+ return DATA_LIMIT; // generate all meaningful precondition states
147
+ // Form mode: complexity-driven
148
+ let complexity = 0;
149
+ for (const f of fields) {
150
+ if (f.dataType === 'enum')
151
+ complexity += Math.min(f.enumValues?.length ?? 1, 6) + 1;
152
+ else if (f.dataType === 'integer' || f.dataType === 'decimal' || f.dataType === 'currency')
153
+ complexity += 5;
154
+ else if (f.dataType === 'date' || f.dataType === 'datetime')
155
+ complexity += 4;
156
+ else if (f.dataType === 'boolean')
157
+ complexity += 2;
158
+ else if (f.dataType === 'phone')
159
+ complexity += 4;
160
+ else
161
+ complexity += 4; // string / email / url
162
+ }
163
+ const kindBonus = kind === 'SECURITY' ? 4
164
+ : kind === 'EDGE' ? 3
165
+ : kind === 'NEGATIVE' ? 3
166
+ : kind === 'ACCESSIBILITY' ? 2
167
+ : 0;
168
+ return Math.max(DATA_MIN, Math.min(DATA_LIMIT, complexity + kindBonus));
169
+ }
170
+ // JSON schema for tool-call mode — forces OpenAI to return valid JSON directly
171
+ const DATA_SETS_SCHEMA = {
172
+ type: 'object',
173
+ properties: {
174
+ dataSets: {
175
+ type: 'array',
176
+ items: {
177
+ type: 'object',
178
+ properties: {
179
+ label: { type: 'string' },
180
+ category: { type: 'string', enum: ['VALID', 'INVALID', 'EDGE', 'SECURITY'] },
181
+ values: { type: 'object', additionalProperties: { type: 'string' } },
182
+ expectedOutcome: { type: 'string' },
183
+ gherkinExample: { type: 'string' },
184
+ },
185
+ required: ['label', 'category', 'values', 'expectedOutcome'],
186
+ },
187
+ },
188
+ },
189
+ required: ['dataSets'],
190
+ };
191
+ const VOLUME_KINDS = new Set(['PERFORMANCE', 'LOAD']);
192
+ // Roles where XSS/SQLi/injection probes are meaningful (have a reflective text surface)
193
+ const REFLECTIVE_ROLES = new Set(['INPUT_TEXT', 'INPUT_EMAIL', 'TEXTAREA', 'INPUT_PASSWORD']);
194
+ const REFLECTIVE_TYPES = new Set(['string', 'email', 'url']);
195
+ function allowsSecurityProbe(f) {
196
+ return f.dataBearing && REFLECTIVE_ROLES.has(f.elementRole) && REFLECTIVE_TYPES.has(f.dataType);
197
+ }
198
+ function fieldStrategy(f) {
199
+ switch (f.dataType) {
200
+ case 'string':
201
+ return `string: VALID-1=use the EXACT example value above (real value from app — do NOT substitute with "validUser" or any placeholder), VALID-2=alternate representative value. INVALID: empty${f.required ? ' (required field violation)' : ''}, ${f.maxLength ? f.maxLength + 1 : '256'}+ chars. EDGE: whitespace-only, leading/trailing spaces, unicode/emoji. BVA on minLength=${f.minLength ?? 0}/maxLength=${f.maxLength ?? 'n'}.`;
202
+ case 'email':
203
+ return `email: VALID-1=use the EXACT example value above (do NOT use "test@example.com" or invented emails), VALID-2=valid+subaddress (user+tag@domain). INVALID: missing @, missing domain, double @, space inside. EDGE: unicode local part, very long.`;
204
+ case 'integer':
205
+ return `integer: BVA → ${(f.minValue ?? 0) - 1}, ${f.minValue ?? 0}, ${(f.minValue ?? 0) + 1} ... ${(f.maxValue ?? 100) - 1}, ${f.maxValue ?? 100}, ${(f.maxValue ?? 100) + 1}; plus zero, negative, decimal-where-int-expected, non-numeric string.`;
206
+ case 'decimal':
207
+ case 'currency':
208
+ return `${f.dataType}: precision boundaries (${f.precision ?? 2} decimal places), zero, negative, very large, more-decimals-than-allowed, non-numeric.`;
209
+ case 'date':
210
+ case 'datetime':
211
+ return 'date: valid, min-date boundary, max-date boundary, invalid format, impossible date (Feb 30, Feb 29 in non-leap year), empty, out-of-allowed-range.';
212
+ case 'enum':
213
+ return `enum: one VALID dataset per allowed value [${(f.enumValues ?? []).join(', ')}]; one INVALID with a value NOT in the set.`;
214
+ case 'boolean':
215
+ return `boolean: true, false${!f.required ? ', missing/null' : ''}.`;
216
+ case 'phone':
217
+ return 'phone: valid with country code, valid without country code, too short, too long, letters mixed in, empty.';
218
+ case 'url':
219
+ return `url: VALID-1=the EXACT example URL shown above (do NOT invent "app.example.com" or any placeholder — use the real URL), VALID-2=same URL with http instead of https. INVALID: missing scheme (no ://), space inside URL, malformed hostname. EDGE: very long URL (500+ chars), empty string.`;
220
+ case 'file':
221
+ return 'file upload: VALID-1=supported file type (e.g. .pdf, .png, .csv) within size limit. VALID-2=another supported type at near-max size. INVALID: unsupported extension (.exe, .bat, .sh, .js); file size exceeding max limit; empty file (0 bytes). EDGE: file exactly at size limit; valid extension with corrupt/truncated content (MIME type mismatch); filename with special characters or very long name (200+ chars). SECURITY: valid extension masking malicious MIME type (e.g. image/jpeg file renamed to .pdf).';
222
+ default:
223
+ return '3 representative valid values + 2 specific invalid values with distinct failure reasons.';
224
+ }
225
+ }
226
+ /**
227
+ * Infer an appropriate dataType string from a camelCase field key name.
228
+ * Used when docs-based test cases have no crawled field schema — the step generator
229
+ * writes {{fieldKey}} placeholders whose types must be guessed from the key name.
230
+ */
231
+ function inferDataTypeFromFieldKey(key) {
232
+ const k = key.toLowerCase();
233
+ // File types
234
+ if (/^(file(name)?|attachment|upload|import(file)?|exportfile)$/.test(k) || k.endsWith('file') || k.endsWith('filename'))
235
+ return 'file';
236
+ // URL types
237
+ if (k === 'url' || k === 'starturl' || k === 'dashboardurl' || k === 'loginurl' || k === 'redirecturl' || k === 'protectedurl' || k.endsWith('url') || k.endsWith('link') || k.endsWith('href') || k.endsWith('endpoint'))
238
+ return 'url';
239
+ // Email types
240
+ if (k === 'email' || k === 'username' || k.endsWith('email') || k === 'recipientemail')
241
+ return 'email';
242
+ // Phone types
243
+ if (k === 'phone' || k.endsWith('phone') || k.endsWith('mobile') || k.endsWith('tel'))
244
+ return 'phone';
245
+ // Integer / count types
246
+ if (k.endsWith('count') || k.endsWith('number') || k.endsWith('qty') || k.endsWith('quantity')
247
+ || k.endsWith('limit') || k.endsWith('total') || k.endsWith('size') || k.endsWith('index')
248
+ || k.endsWith('rowcount') || k.endsWith('pagecount') || k.endsWith('paircount') || k.endsWith('rowcount')
249
+ || /^(count|total|qty|quantity|size|index|limit|page)$/.test(k))
250
+ return 'integer';
251
+ // Currency / price types
252
+ if (k.endsWith('price') || k.endsWith('cost') || k.endsWith('fee') || k.endsWith('amount') || k.endsWith('balance'))
253
+ return 'currency';
254
+ // Date / time types
255
+ if (k.endsWith('date') || k.endsWith('time') || k.endsWith('at') || k.endsWith('on')
256
+ || k === 'startdate' || k === 'enddate' || k === 'duedate' || k === 'expiry' || k === 'expirydate')
257
+ return 'date';
258
+ // Boolean flags
259
+ if (k.endsWith('enabled') || k.endsWith('active') || k.endsWith('visible') || k.endsWith('published')
260
+ || k.endsWith('flag') || k.endsWith('toggle') || k.startsWith('is') || k.startsWith('has'))
261
+ return 'boolean';
262
+ // Default to string for everything else
263
+ return 'string';
264
+ }
265
+ /**
266
+ * Parse FZE multi-select encoded value into a human-readable comma-separated list.
267
+ * Format: OR|FUNCOPR|Option1|FUNCVAL|Option2|...
268
+ */
269
+ function normalizeFzeMultiSelectValue(val) {
270
+ if (!val.includes('FUNCOPR') && !val.includes('FUNCVAL'))
271
+ return val;
272
+ const options = [];
273
+ const parts = val.split('|');
274
+ let inOption = false;
275
+ for (let i = 0; i < parts.length; i++) {
276
+ if (parts[i] === 'FUNCOPR' || parts[i] === 'FUNCVAL' || parts[i] === 'OR') {
277
+ inOption = true;
278
+ }
279
+ else if (inOption && parts[i].trim()) {
280
+ options.push(parts[i].trim());
281
+ inOption = false;
282
+ }
283
+ }
284
+ return options.length > 0 ? options.join(', ') : val;
285
+ }
286
+ function isValidExampleValue(v) {
287
+ if (v === null || v === undefined)
288
+ return false;
289
+ const s = String(v).trim();
290
+ return (s.length > 0 &&
291
+ s.length < 300 &&
292
+ s !== '[object Object]' &&
293
+ s !== 'undefined' &&
294
+ s !== 'null');
295
+ }
296
+ function buildFieldsBlock(fields) {
297
+ return fields.map((f) => {
298
+ const parts = [` ${f.fieldKey} (${f.dataType}, ${f.elementRole}${f.required ? ', required' : ''})`];
299
+ if (f.minLength != null)
300
+ parts.push(` minLength: ${f.minLength}`);
301
+ if (f.maxLength != null)
302
+ parts.push(` maxLength: ${f.maxLength}`);
303
+ if (f.minValue != null)
304
+ parts.push(` minValue: ${f.minValue}`);
305
+ if (f.maxValue != null)
306
+ parts.push(` maxValue: ${f.maxValue}`);
307
+ if (f.enumValues?.length)
308
+ parts.push(` allowedValues: [${f.enumValues.join(', ')}]`);
309
+ if (f.regex)
310
+ parts.push(` pattern: ${f.regex}`);
311
+ if (f.exampleValue)
312
+ parts.push(` example: ${f.exampleValue}`);
313
+ if (f.notes)
314
+ parts.push(` businessRule: ${f.notes}`);
315
+ parts.push(` STRATEGY: ${fieldStrategy(f)}`);
316
+ if (allowsSecurityProbe(f))
317
+ parts.push(' SECURITY: generate XSS, SQLi, path-traversal probe datasets for this field.');
318
+ return parts.join('\n');
319
+ }).join('\n\n');
320
+ }
321
+ /**
322
+ * Robustly extracts a JSON object from LLM output.
323
+ * Handles: preamble text with stray { chars (e.g. "{login}"), markdown code fences,
324
+ * literal control characters in string values.
325
+ */
326
+ function extractJson(raw) {
327
+ let slice = null;
328
+ // Strategy 1: anchor on the known "dataSets" key — most reliable for our output shape
329
+ const dsIdx = raw.indexOf('"dataSets"');
330
+ if (dsIdx !== -1) {
331
+ let start = dsIdx;
332
+ while (start > 0 && raw[start] !== '{')
333
+ start--;
334
+ const end = raw.lastIndexOf('}');
335
+ if (start >= 0 && end > start)
336
+ slice = raw.slice(start, end + 1);
337
+ }
338
+ // Strategy 2: strip markdown code fences then find outermost { }
339
+ if (!slice) {
340
+ const stripped = raw.replace(/```[a-zA-Z]*\n?/g, '').trim();
341
+ const j = stripped.indexOf('{');
342
+ const je = stripped.lastIndexOf('}');
343
+ if (j !== -1 && je !== -1)
344
+ slice = stripped.slice(j, je + 1);
345
+ }
346
+ if (!slice)
347
+ throw new Error('No JSON object found in LLM response');
348
+ // Try parse; on failure sanitize literal control characters ONLY inside string values
349
+ try {
350
+ return JSON.parse(slice);
351
+ }
352
+ catch {
353
+ let clean = '';
354
+ let inStr = false;
355
+ for (let i = 0; i < slice.length; i++) {
356
+ const c = slice[i];
357
+ if (c === '\\' && inStr) {
358
+ clean += c + slice[++i];
359
+ continue;
360
+ }
361
+ if (c === '"') {
362
+ inStr = !inStr;
363
+ clean += c;
364
+ continue;
365
+ }
366
+ if (inStr) {
367
+ if (c === '\n') {
368
+ clean += '\\n';
369
+ continue;
370
+ }
371
+ if (c === '\r') {
372
+ clean += '\\r';
373
+ continue;
374
+ }
375
+ if (c === '\t') {
376
+ clean += '\\t';
377
+ continue;
378
+ }
379
+ }
380
+ clean += c;
381
+ }
382
+ return JSON.parse(clean);
383
+ }
384
+ }
385
+ const HEDGING_RE = /\b(possibly|potentially|may |might |should probably|could )\b/i;
386
+ function rejectHedgedOutcomes(dataSets) {
387
+ return dataSets.filter((ds) => {
388
+ if (HEDGING_RE.test(ds.expectedOutcome)) {
389
+ console.warn(`[data-agent] Dropped hedged outcome in "${ds.label}": "${ds.expectedOutcome}"`);
390
+ return false;
391
+ }
392
+ return true;
393
+ });
394
+ }
395
+ const DATA_INTENT_STOP_WORDS = new Set([
396
+ 'test', 'case', 'scenario', 'user', 'page', 'input', 'button', 'field',
397
+ 'click', 'enter', 'select', 'navigate', 'verify', 'validate', 'correctly',
398
+ 'through', 'with', 'from', 'this', 'that', 'when', 'then', 'given', 'and',
399
+ 'the', 'has', 'have', 'had', 'for', 'are', 'was', 'were', 'been', 'being',
400
+ 'will', 'would', 'should', 'could', 'model', 'models', 'catalog',
401
+ ]);
402
+ const GENERIC_OTHER_SCENARIO_RE = /\b(empty|blank|missing|required|invalid|malformed|non[-\s]?existent|no results?|first use|first-use|rapid|subsequent|simultaneous|different devices?|mobile|desktop|unicode|emoji|xss|sqli|sql injection|path traversal|permission|unauthenticated|expired session)\b/i;
403
+ const NEGATIVE_INTENT_RE = /\b(invalid|reject|error|blocked|permission|unauthenticated|unauthorized|expired|no results?|not found|empty|required|missing|fail)\b/i;
404
+ const OVERFLOW_INTENT_RE = /\b(overflow|excess|excessive|numerous|high number|many|large number|pagination|loading|performance|volume|load|results)\b/i;
405
+ const SEARCH_INTENT_RE = /\b(search|find|query|lookup|filter|browse|locate)\b/i;
406
+ const OVERFLOW_DATA_RE = /\b(overflow|excess|excessive|numerous|high|many|large|pagination|loading|performance|volume|load|results|maxResults|rowCount|pageSize|common term)\b/i;
407
+ function intentText(testCase) {
408
+ return [
409
+ testCase?.title,
410
+ testCase?.expectedResult,
411
+ testCase?.preconditions,
412
+ Array.isArray(testCase?.tags) ? testCase.tags.join(' ') : '',
413
+ testCase?.techniqueOfRecord,
414
+ (0, step_utils_js_1.normalizeSteps)(testCase?.steps).join(' '),
415
+ ].filter(Boolean).join(' ');
416
+ }
417
+ function intentTokens(text) {
418
+ return new Set(text
419
+ .toLowerCase()
420
+ .replace(/[^a-z0-9\s-]/g, ' ')
421
+ .split(/\s+/)
422
+ .map((token) => token.trim())
423
+ .filter((token) => token.length >= 4 && !DATA_INTENT_STOP_WORDS.has(token)));
424
+ }
425
+ function datasetText(ds) {
426
+ return [
427
+ ds.label,
428
+ ds.category,
429
+ ds.expectedOutcome,
430
+ JSON.stringify(ds.values ?? {}),
431
+ ].join(' ');
432
+ }
433
+ function hasTokenOverlap(dsText, tokens) {
434
+ const lower = dsText.toLowerCase();
435
+ for (const token of tokens) {
436
+ if (lower.includes(token))
437
+ return true;
438
+ }
439
+ return false;
440
+ }
441
+ function enforceIntentScope(dataSets, testCase, kind, mode) {
442
+ if (mode === 'volume')
443
+ return dataSets;
444
+ const text = intentText(testCase);
445
+ const tokens = intentTokens(text);
446
+ const hasNegativeIntent = NEGATIVE_INTENT_RE.test(text);
447
+ const hasOverflowIntent = OVERFLOW_INTENT_RE.test(text);
448
+ const hasSearchIntent = SEARCH_INTENT_RE.test(text);
449
+ // E2E tests are multi-step positive flows (recorded journeys) — treat like positive
450
+ const positiveLike = kind === 'POSITIVE' || kind === 'SMOKE' || kind === 'REGRESSION' || kind === 'SANITY' || kind === 'UAT' || kind === 'E2E';
451
+ const filtered = dataSets.filter((ds) => {
452
+ const category = String(ds.category ?? '').toUpperCase();
453
+ const dsText = datasetText(ds);
454
+ // STATE mode: INVALID = "bad precondition state" (server 503, page unavailable) — valid even for SMOKE.
455
+ // FORM mode: INVALID = "invalid user input" — genuinely out-of-scope for positive/smoke tests.
456
+ if (positiveLike && !hasNegativeIntent && mode !== 'state' && (category === 'INVALID' || category === 'SECURITY')) {
457
+ console.warn(`[data-agent] Dropped ${category} dataset outside positive test intent: "${ds.label}"`);
458
+ return false;
459
+ }
460
+ // E2E tests are recorded multi-step journeys — EDGE (boundary/minimal inputs) are irrelevant.
461
+ if (kind === 'E2E' && mode === 'form' && category === 'EDGE') {
462
+ console.warn(`[data-agent] Dropped EDGE dataset for E2E recorded journey: "${ds.label}"`);
463
+ return false;
464
+ }
465
+ if (!positiveLike && hasNegativeIntent && category === 'VALID') {
466
+ console.warn(`[data-agent] Dropped VALID dataset from negative-intent test: "${ds.label}"`);
467
+ return false;
468
+ }
469
+ if (!hasNegativeIntent && GENERIC_OTHER_SCENARIO_RE.test(dsText)) {
470
+ if (!hasOverflowIntent || !OVERFLOW_DATA_RE.test(dsText)) {
471
+ console.warn(`[data-agent] Dropped generic/off-intent dataset: "${ds.label}"`);
472
+ return false;
473
+ }
474
+ }
475
+ if (hasOverflowIntent && !OVERFLOW_DATA_RE.test(dsText)) {
476
+ console.warn(`[data-agent] Dropped non-overflow dataset for overflow/pagination intent: "${ds.label}"`);
477
+ return false;
478
+ }
479
+ // In form mode, enforceFieldKeys already ensures only known field keys are present —
480
+ // token overlap check is redundant and incorrectly drops INVALID/EDGE datasets whose
481
+ // labels ("empty required field", "boundary value") don't mention test intent keywords.
482
+ // Token overlap check is only meaningful in state mode to filter off-topic preconditions.
483
+ if (mode !== 'form' && tokens.size > 0 && !hasTokenOverlap(dsText, tokens) && category !== 'VALID') {
484
+ console.warn(`[data-agent] Dropped dataset without test-intent overlap: "${ds.label}"`);
485
+ return false;
486
+ }
487
+ return true;
488
+ });
489
+ const scoped = filtered.length > 0
490
+ ? filtered
491
+ : hasNegativeIntent
492
+ ? dataSets.filter((ds) => {
493
+ const cat = String(ds.category ?? '').toUpperCase();
494
+ return cat === 'INVALID' || cat === 'EDGE' || cat === 'SECURITY';
495
+ })
496
+ : dataSets.filter((ds) => String(ds.category ?? '').toUpperCase() === 'VALID');
497
+ const maxRows = DATA_LIMIT;
498
+ let finalScoped = scoped;
499
+ if (hasSearchIntent && finalScoped.length > 0 && !finalScoped.some((ds) => {
500
+ const dsText = datasetText(ds);
501
+ return /no.results?|empty|zero|not.found/i.test(dsText);
502
+ })) {
503
+ const noResultsRow = dataSets.find((ds) => {
504
+ const dsText = datasetText(ds);
505
+ return /no.results?|empty|zero|not.found/i.test(dsText);
506
+ });
507
+ if (noResultsRow)
508
+ finalScoped = [...finalScoped, noResultsRow];
509
+ }
510
+ return finalScoped.slice(0, maxRows);
511
+ }
512
+ function normalizeTargetCount(dataSets, targetCount, fallbackRows) {
513
+ if (!targetCount || !Number.isFinite(targetCount))
514
+ return dataSets;
515
+ // Max capped by DATA_AGENT_MAX_ROWS (env), default 100 — allows full dataset generation
516
+ const hardMax = parseInt(process.env.DATA_AGENT_MAX_ROWS ?? '100', 10);
517
+ const count = Math.max(1, Math.min(hardMax, Math.floor(targetCount)));
518
+ const unique = [];
519
+ const seen = new Set();
520
+ for (const ds of dataSets) {
521
+ const key = `${String(ds.category).toUpperCase()}|${JSON.stringify(ds.values)}|${ds.expectedOutcome}`;
522
+ if (seen.has(key))
523
+ continue;
524
+ seen.add(key);
525
+ unique.push(ds);
526
+ }
527
+ if (unique.length >= count)
528
+ return unique.slice(0, count);
529
+ // Do not pad with clones — return whatever unique datasets were generated.
530
+ // Padding with copies of existing rows produces the illusion of more coverage without value.
531
+ return unique;
532
+ }
533
+ function relatedCaseExclusionBlock(testCase, cases, groundedOn) {
534
+ const currentId = testCase?.id;
535
+ const currentScreenId = testCase?.screenId;
536
+ const grounded = new Set((groundedOn ?? []).map(String));
537
+ const related = cases
538
+ .filter((tc) => tc?.id !== currentId)
539
+ .filter((tc) => {
540
+ if (currentScreenId && tc?.screenId === currentScreenId)
541
+ return true;
542
+ const otherGrounded = Array.isArray(tc?.groundedOn) ? tc.groundedOn.map(String) : [];
543
+ return otherGrounded.some((id) => grounded.has(id));
544
+ })
545
+ .slice(0, DATA_LIMIT);
546
+ if (related.length === 0)
547
+ return '';
548
+ return [
549
+ 'RELATED TEST CASES ALREADY COVER SEPARATE SCENARIOS:',
550
+ ...related.map((tc) => {
551
+ const steps = (0, step_utils_js_1.normalizeSteps)(tc.steps).slice(0, 3).join(' | ');
552
+ return ` - ${tc.ref ?? tc.id}: ${tc.title}${steps ? ` — ${steps}` : ''}`;
553
+ }),
554
+ 'Do NOT generate datasets for those related scenarios. Generate only data needed by the current TEST CASE.',
555
+ ].join('\n');
556
+ }
557
+ function enforceFieldKeys(dataSets, allowedKeys, dataBearingFields = []) {
558
+ return dataSets.map((ds) => {
559
+ const cleaned = {};
560
+ for (const [k, v] of Object.entries(ds.values)) {
561
+ if (allowedKeys.has(k)) {
562
+ // LLM-generated test values are fake (not real scraped PII) — use them as-is.
563
+ // piiClass=high only blocks real extracted values, not synthetic test data.
564
+ cleaned[k] = v;
565
+ }
566
+ else {
567
+ console.warn(`[data-agent] Dropped invented key "${k}" not in field catalog.`);
568
+ }
569
+ }
570
+ return { ...ds, values: cleaned };
571
+ }).filter((ds) => Object.keys(ds.values).length > 0);
572
+ }
573
+ /**
574
+ * Post-parse constraint enforcement for VALID datasets only.
575
+ * INVALID/EDGE/SECURITY datasets intentionally use out-of-range values to test
576
+ * validation — do not clamp those. VALID datasets must always satisfy field constraints.
577
+ *
578
+ * Rules applied:
579
+ * - numeric (integer/decimal/currency) with min/maxValue: clamp to [min, max]
580
+ * - enum fields: if generated value not in enumValues, use the first valid option
581
+ * - string fields with maxLength: truncate to maxLength
582
+ */
583
+ function clampConstrainedValues(dataSets, fields) {
584
+ if (fields.length === 0)
585
+ return dataSets;
586
+ const fieldMap = new Map(fields.map(f => [f.fieldKey, f]));
587
+ return dataSets.map((ds) => {
588
+ const category = String(ds.category ?? '').toUpperCase();
589
+ // Only clamp VALID datasets — INVALID/EDGE/SECURITY intentionally violate constraints
590
+ if (category !== 'VALID')
591
+ return ds;
592
+ const values = { ...ds.values };
593
+ for (const [key, rawVal] of Object.entries(values)) {
594
+ const field = fieldMap.get(key);
595
+ if (!field)
596
+ continue;
597
+ const val = String(rawVal ?? '');
598
+ // Numeric clamp for integer, decimal, currency
599
+ if (field.dataType === 'integer' || field.dataType === 'decimal' || field.dataType === 'currency') {
600
+ const num = parseFloat(val);
601
+ if (!isNaN(num)) {
602
+ let clamped = num;
603
+ if (field.minValue != null && clamped < field.minValue) {
604
+ console.warn(`[data-agent] Clamped ${key}=${num} to minValue=${field.minValue} in VALID dataset "${ds.label}"`);
605
+ clamped = field.minValue;
606
+ }
607
+ if (field.maxValue != null && clamped > field.maxValue) {
608
+ console.warn(`[data-agent] Clamped ${key}=${num} to maxValue=${field.maxValue} in VALID dataset "${ds.label}"`);
609
+ clamped = field.maxValue;
610
+ }
611
+ if (clamped !== num) {
612
+ values[key] = field.dataType === 'integer' ? String(Math.round(clamped)) : String(clamped);
613
+ }
614
+ }
615
+ continue;
616
+ }
617
+ // Enum fix: if value not in allowed options, use first valid option
618
+ if (field.dataType === 'enum' && field.enumValues && field.enumValues.length > 0) {
619
+ if (!field.enumValues.includes(val)) {
620
+ console.warn(`[data-agent] Fixed enum ${key}="${val}" → "${field.enumValues[0]}" in VALID dataset "${ds.label}"`);
621
+ values[key] = field.enumValues[0];
622
+ }
623
+ continue;
624
+ }
625
+ // String maxLength truncation
626
+ if (field.maxLength != null && val.length > field.maxLength) {
627
+ console.warn(`[data-agent] Truncated ${key} from ${val.length} to maxLength=${field.maxLength} in VALID dataset "${ds.label}"`);
628
+ values[key] = val.slice(0, field.maxLength);
629
+ }
630
+ }
631
+ return { ...ds, values };
632
+ });
633
+ }
634
+ async function generateTestData(opts) {
635
+ const testCase = await app_brain_1.AppBrain.getTestCaseWithContext(opts.tenantId, opts.testCaseId);
636
+ if (!testCase)
637
+ throw new Error('Test case not found.');
638
+ const kind = testCase.kind;
639
+ const groundedOn = testCase.groundedOn;
640
+ if (SKIP_KINDS.has(kind)) {
641
+ return { dataSets: [], saved: [], skipped: true, reason: `kind=${kind} does not benefit from parameterized test data` };
642
+ }
643
+ // Parallelize all independent DB lookups — saves ~250ms of sequential round-trips
644
+ const [fields, skillFile, errorCatalogueFile, projectCases, llmCfg] = await Promise.all([
645
+ app_brain_1.AppBrain.getFieldsForCase(opts.tenantId, opts.projectId, groundedOn),
646
+ _cachedSkillFile(opts.tenantId, opts.projectId, 'TEST_DATA'),
647
+ _cachedSkillFile(opts.tenantId, opts.projectId, 'ERROR_CATALOGUE'),
648
+ app_brain_1.AppBrain.listTestCaseSummaries(opts.tenantId, opts.projectId, 50).catch(() => []),
649
+ _cachedLLMConfig(opts.tenantId),
650
+ ]);
651
+ let dataBearingFields = fields.filter((f) => f.dataBearing);
652
+ // VOLUME mode: PERFORMANCE/LOAD get data-setup specs, not field:value datasets
653
+ if (VOLUME_KINDS.has(kind)) {
654
+ const { system: vsDefault, user: vu } = buildVolumePrompt({ testCase, kind });
655
+ const vs = await (0, core_1.getAgentPrompt)('data-gen-api', undefined, opts.tenantId).catch(() => null) ?? vsDefault;
656
+ let volumeDataSets = fallbackDataSets(kind, 'volume', fields);
657
+ try {
658
+ const t0v = Date.now();
659
+ const { text: raw, usage: vUsage } = await (0, llm_config_js_1.tryWithPlatformFallback)(llmCfg, (cfg) => (0, llm_js_1.askTracked)(vs, vu, cfg, 4096, DATA_SETS_SCHEMA, { temperature: 0, seed: (0, llm_js_1.deterministicSeed)(`${opts.tenantId}:${opts.projectId}:${opts.testCaseId}:volume:${kind}`) }));
660
+ try {
661
+ const vParsed = extractJson(raw);
662
+ if (Array.isArray(vParsed.dataSets))
663
+ volumeDataSets = vParsed.dataSets;
664
+ }
665
+ catch { /* fallback already set */ }
666
+ (0, app_brain_1.recordLLMCall)({
667
+ tenantId: opts.tenantId, projectId: opts.projectId, jobId: opts.jobId,
668
+ agentType: 'data', label: `test_data_volume_${kind}`,
669
+ entityId: opts.testCaseId, entityType: 'test_data',
670
+ usage: vUsage, latencyMs: Date.now() - t0v,
671
+ }).catch(console.error);
672
+ }
673
+ catch { /* fallback already set */ }
674
+ volumeDataSets = normalizeTargetCount(volumeDataSets, opts.targetCount, fallbackDataSets(kind, 'volume', fields));
675
+ const saved = await app_brain_1.AppBrain.saveTestData(opts.tenantId, opts.projectId, opts.testCaseId, volumeDataSets.map(d => ({ ...d, source: 'generated' })));
676
+ return { dataSets: volumeDataSets, saved, mode: 'volume', source: 'generated' };
677
+ }
678
+ const baseCtx = skillFile?.content ?? '{"domain":"unknown"}';
679
+ const errorCtx = errorCatalogueFile?.content
680
+ ? `\n\n## Error Catalogue (use exact error text for INVALID dataset expectedOutcome):\n${errorCatalogueFile.content.slice(0, 800)}`
681
+ : '';
682
+ const domainCtx = baseCtx + errorCtx;
683
+ const inlineData = testCase.testDataInline;
684
+ const dataNote = testCase.dataNote;
685
+ const structuredSteps = testCase.structuredSteps;
686
+ // For imported test cases (groundedOn=[]) that have recorded form field values in testDataInline,
687
+ // synthesize ephemeral FieldSchema records so FORM mode generates real field:value datasets
688
+ // instead of falling through to STATE mode (which produces role/precondition data).
689
+ if (dataBearingFields.length === 0 && inlineData && Object.keys(inlineData).length > 0) {
690
+ // Metadata-only keys that are not form inputs — skip from field synthesis
691
+ const SKIP_KEYS = new Set(['customScripts', 'contextSwitches', 'recordedUrls', 'recordedSelections']);
692
+ // Keys that map to canonical form field names
693
+ const KEY_RENAME = {
694
+ recordedUser: 'username',
695
+ recordedPassword: 'password',
696
+ };
697
+ const _fzeDynamicRe = /^\{\{\s*fze\.(random|local)\./i;
698
+ const _fzeRandomEmailRe = /fze\.randomEmail\s*\(\s*['"]([^'"]+)['"]\s*\)/i;
699
+ const _fzeRandomTextRe = /fze\.randomText\s*\(\s*(\d+)\s*\)/i;
700
+ const _fzeRandomPhoneRe = /fze\.randomPhone/i;
701
+ const _fzeRandomNumberRe = /fze\.randomNumber/i;
702
+ const _sampleForDynamic = (raw, fieldKey) => {
703
+ const k = fieldKey.toLowerCase();
704
+ if (_fzeRandomEmailRe.test(raw)) {
705
+ const dom = raw.match(_fzeRandomEmailRe)?.[1] ?? 'example.com';
706
+ const _fn = ['james', 'sarah', 'michael', 'emily', 'david', 'jessica', 'robert', 'linda'][Math.floor(Math.random() * 8)];
707
+ const _ln = ['smith', 'jones', 'chen', 'wilson', 'taylor', 'brown', 'davis', 'martin'][Math.floor(Math.random() * 8)];
708
+ return `${_fn}.${_ln}@${dom}`;
709
+ }
710
+ if (_fzeRandomPhoneRe.test(raw))
711
+ return '555-123-4567';
712
+ if (_fzeRandomNumberRe.test(raw))
713
+ return '12345';
714
+ if (_fzeRandomTextRe.test(raw)) {
715
+ const n = Math.max(1, Number(raw.match(_fzeRandomTextRe)?.[1] ?? 8));
716
+ if (/first.?name|fname|szfirst/i.test(k))
717
+ return 'John'.slice(0, n).padEnd(n > 4 ? n : 4, 'x').slice(0, n);
718
+ if (/last.?name|lname|surname|szlast/i.test(k))
719
+ return 'Smith'.slice(0, n).padEnd(n > 5 ? n : 5, 'h').slice(0, n);
720
+ if (/name/i.test(k))
721
+ return 'Sample'.slice(0, n) || 'Sample';
722
+ if (/comment|note|message|desc/i.test(k))
723
+ return 'Test comment text'.slice(0, n) || 'TestText';
724
+ if (/city|town/i.test(k))
725
+ return 'Springfield'.slice(0, n) || 'City';
726
+ if (/address|street/i.test(k))
727
+ return '123 Main St'.slice(0, n) || 'Address';
728
+ return 'TestValue'.slice(0, n) || 'TestValue';
729
+ }
730
+ return 'SampleValue';
731
+ };
732
+ dataBearingFields = Object.entries(inlineData)
733
+ .filter(([key, v]) => !SKIP_KEYS.has(key) &&
734
+ !key.startsWith('artifact_') &&
735
+ !Array.isArray(v) &&
736
+ v !== null && v !== undefined &&
737
+ typeof v !== 'object' &&
738
+ String(v).trim().length > 0 &&
739
+ String(v).trim() !== '[object Object]' &&
740
+ String(v).length < 500
741
+ // fze.random* fields are now INCLUDED — sample values generated below
742
+ )
743
+ .map(([key, val]) => {
744
+ const rawVal = String(val).trim();
745
+ const fieldKey = KEY_RENAME[key] ?? key;
746
+ const lower = fieldKey.toLowerCase();
747
+ const isPassword = lower.includes('password') || lower.includes('pwd') || lower.includes('secret');
748
+ const isDynamic = _fzeDynamicRe.test(rawVal);
749
+ const isEmail = lower.includes('email') || /fze\.randomEmail/i.test(rawVal);
750
+ const exampleValue = isPassword ? 'Test@123!' : isDynamic ? _sampleForDynamic(rawVal, fieldKey) : rawVal;
751
+ return {
752
+ fieldKey,
753
+ label: fieldKey.replace(/([A-Z])/g, ' $1').trim(),
754
+ elementRole: (isPassword ? 'INPUT_PASSWORD' : isEmail ? 'INPUT_EMAIL' : 'INPUT_TEXT'),
755
+ dataBearing: true,
756
+ dataType: (isEmail ? 'email' : 'string'),
757
+ required: true,
758
+ piiClass: (isPassword ? 'high' : 'none'),
759
+ exampleValue,
760
+ };
761
+ });
762
+ // Add URL environment field — use ONLY first URL as the entry point.
763
+ // recordedUrls contains navigation history (18+ URLs); exposing all as enumValues
764
+ // causes the LLM to generate URL-scheme variations instead of meaningful data.
765
+ const recordedUrls = inlineData.recordedUrls;
766
+ if (Array.isArray(recordedUrls) && recordedUrls.length > 0) {
767
+ dataBearingFields.push({
768
+ fieldKey: 'startUrl',
769
+ label: 'Start URL',
770
+ elementRole: 'INPUT_TEXT',
771
+ dataBearing: true,
772
+ dataType: 'url',
773
+ required: false,
774
+ piiClass: 'none',
775
+ exampleValue: recordedUrls[0],
776
+ });
777
+ }
778
+ // Add recorded filter/selection values from UI interactions.
779
+ // These include dropdown item clicks, date range selections, filter values, etc.
780
+ const recordedSelections = inlineData.recordedSelections;
781
+ if (Array.isArray(recordedSelections) && recordedSelections.length > 0) {
782
+ // Detect date ranges in selections to choose a more accurate label
783
+ const hasDate = recordedSelections.some(s => /\d{1,2}\/\d{1,2}\/\d{4}/.test(s));
784
+ const label = hasDate ? 'Date Range / Filter Values' : 'Recorded Filter Selections';
785
+ dataBearingFields.push({
786
+ fieldKey: 'selectedFilterValues',
787
+ label: label,
788
+ elementRole: 'INPUT_TEXT',
789
+ dataBearing: true,
790
+ dataType: 'string',
791
+ required: false,
792
+ piiClass: 'none',
793
+ exampleValue: recordedSelections[0],
794
+ enumValues: recordedSelections.slice(0, 20),
795
+ });
796
+ }
797
+ }
798
+ // Extract fieldKey: value pairs from the data property of FZE/Functionize step objects.
799
+ // Steps from imported tests store raw form field bindings as data: "szZip: 10008".
800
+ // normalizeSteps/stepToText drops the data property when action is present, so we
801
+ // read raw step objects directly — the old → fieldKey: value regex never matched anything.
802
+ {
803
+ const rawSteps = Array.isArray(testCase.steps) ? testCase.steps : [];
804
+ const existingKeys = new Set(dataBearingFields.map((f) => f.fieldKey));
805
+ // Track all seen values — catches alias fields with same value (e.g. zipField≈szZip)
806
+ // even when groundedOn screen fields don't carry recorded exampleValues.
807
+ const seenValues = new Set(dataBearingFields.map(f => String(f.exampleValue ?? '').trim()).filter(v => v.length > 0 && !/^\{\{\s*fze\.(random|local)\./i.test(v)));
808
+ const SKIP_FZE_KEYS = new Set([
809
+ 'script', 'customcode', 'customcode_1', 'customcode_2', 'customcode_3',
810
+ // Functionize recording metadata — not test input data
811
+ // Note: 'recordedpassword' is intentionally NOT skipped — it reaches the isPassword
812
+ // branch below which masks the actual value to '[protected]' and includes the field.
813
+ 'recordedurls', 'recordeduser', 'recordedselections',
814
+ 'contextswitches', 'recordedurl', 'recordedselection',
815
+ // Navigation fields — not form inputs
816
+ 'starturl', 'startUrl', 'navigateurl', 'pageurl',
817
+ ]);
818
+ const STEP_DATA_RE = /^([a-zA-Z_][a-zA-Z0-9_]{1,50})\s*:\s*(.+)/;
819
+ for (const step of rawSteps) {
820
+ if (typeof step !== 'object' || !step)
821
+ continue;
822
+ const dataStr = typeof step.data === 'string' ? step.data.trim() : '';
823
+ if (!dataStr)
824
+ continue;
825
+ const m = dataStr.match(STEP_DATA_RE);
826
+ if (!m)
827
+ continue;
828
+ const rawKey = m[1].trim();
829
+ const rawVal = m[2].trim();
830
+ if (SKIP_FZE_KEYS.has(rawKey.toLowerCase()))
831
+ continue;
832
+ // Canonical key: strip Hungarian prefix (sz/txt/list...) AND common suffixes (Field/Input/etc.)
833
+ // so szUsername matches 'username' and firstNameField matches 'szFirstName' (via 'firstname').
834
+ const _canonKey = rawKey
835
+ .replace(/^(sz|txt|list|lbl|btn|chk|l|n)(?=[A-Z])/i, '')
836
+ .replace(/^(.)/, (c) => c.toLowerCase())
837
+ .replace(/(Field|Input|Select|Dropdown|Button|Text|Value)$/i, '')
838
+ .toLowerCase();
839
+ if (existingKeys.has(rawKey) || existingKeys.has(_canonKey))
840
+ continue;
841
+ if (rawVal.startsWith('script:'))
842
+ continue;
843
+ // FZE multi-select format: "OR|FUNCOPR|Option1|FUNCVAL|Option2|..." — normalize to readable list
844
+ const isFzeMultiSelect = rawVal.startsWith('OR|FUNCOPR') || rawVal.includes('|FUNCVAL|');
845
+ const normalizedVal = isFzeMultiSelect ? normalizeFzeMultiSelectValue(rawVal) : rawVal;
846
+ // FZE dynamic generator: {{fze.randomText(8)}} — extract generator info for LLM guidance
847
+ const fzeGeneratorMatch = rawVal.match(/^\{\{\s*fze\.(random\w+)\s*\(([^)]*)\)\s*\}\}$/i);
848
+ if (!isFzeMultiSelect && !fzeGeneratorMatch && /^\{\{\s*fze\.(random|local)\./i.test(rawVal.trim()))
849
+ continue; // skip other fze.random*/fze.local.* (dynamic); keep fze.env.* (static)
850
+ const lower = rawKey.toLowerCase();
851
+ const isPassword = /pass|pwd|secret/.test(lower);
852
+ const isEmail = /email|mail/.test(lower) || /^[^@]+@[^@]+\.[^@]+$/.test(normalizedVal);
853
+ const isDate = /date|birthday|dob|birth/.test(lower) || /\d{1,2}\/\d{1,2}\/\d{4}/.test(normalizedVal);
854
+ const isSsn = /ssn|sin|social/.test(lower);
855
+ const isZip = /zip|postal|post_?code/.test(lower);
856
+ const isDynamic = rawVal.startsWith('dynamic');
857
+ const label = rawKey
858
+ .replace(/^(sz|txt|list|lbl|btn|chk|l|n)/i, '')
859
+ .replace(/([A-Z])/g, ' $1')
860
+ .trim()
861
+ .replace(/^./, (c) => c.toUpperCase()) || rawKey;
862
+ // Skip alias fields that carry a value already covered by another field key.
863
+ if (seenValues.has(normalizedVal))
864
+ continue;
865
+ seenValues.add(normalizedVal);
866
+ // Determine exampleValue: password → [protected], fze generator → [dynamic:fn(args)],
867
+ // multi-select → normalized human-readable list, dynamic prefix → generic placeholder, else real value.
868
+ let exampleValue;
869
+ if (isPassword) {
870
+ exampleValue = '[protected]';
871
+ }
872
+ else if (fzeGeneratorMatch) {
873
+ const generatorFn = fzeGeneratorMatch[1];
874
+ const generatorArgs = fzeGeneratorMatch[2].trim();
875
+ exampleValue = `[dynamic:${generatorFn}(${generatorArgs})]`;
876
+ }
877
+ else if (isDynamic) {
878
+ exampleValue = isEmail ? 'test.user@example.com' : lower.includes('first') ? 'John' : lower.includes('last') ? 'Doe' : 'TestValue';
879
+ }
880
+ else {
881
+ exampleValue = normalizedVal.slice(0, 200);
882
+ }
883
+ const isCredentialUsername = /^(user|username|login|email|account)$/i.test(_canonKey);
884
+ const isCredentialPassword = isPassword;
885
+ existingKeys.add(rawKey);
886
+ dataBearingFields.push({
887
+ fieldKey: rawKey,
888
+ label,
889
+ elementRole: (isPassword ? 'INPUT_PASSWORD' : isEmail ? 'INPUT_EMAIL' : isDate ? 'INPUT_DATE' : 'INPUT_TEXT'),
890
+ dataBearing: true,
891
+ dataType: (isEmail ? 'email' : isDate ? 'date' : isZip ? 'zip' : 'string'),
892
+ required: !lower.includes('comment') && !lower.includes('note') && !lower.includes('type'),
893
+ piiClass: (isPassword || isSsn ? 'high' : isEmail ? 'medium' : 'none'),
894
+ exampleValue,
895
+ ...(isCredentialUsername || isCredentialPassword ? { credentialGroup: 'login' } : {}),
896
+ });
897
+ }
898
+ }
899
+ // Docs-based field inference: when there are no crawled fields AND no inline data recorded,
900
+ // the test came from a docs/requirements flow. Parse all {{fieldKey}} placeholders from step
901
+ // text and infer an appropriate data type from the field name — domain-agnostic.
902
+ if (dataBearingFields.length === 0 && (!inlineData || Object.keys(inlineData).length === 0)) {
903
+ const rawStepsInf = Array.isArray(testCase.steps) ? testCase.steps : [];
904
+ const allStepTextInf = rawStepsInf
905
+ .map((s) => typeof s === 'string' ? s : `${s.action ?? ''} ${s.data ?? ''} ${s.expected ?? ''}`.trim())
906
+ .join('\n');
907
+ const inferredKeys = new Set();
908
+ const placeholderRe = /\{\{([a-zA-Z][a-zA-Z0-9_]{1,39})\}\}/g;
909
+ let pm;
910
+ while ((pm = placeholderRe.exec(allStepTextInf)) !== null) {
911
+ const key = pm[1];
912
+ if (!inferredKeys.has(key) && !dataBearingFields.some(f => f.fieldKey === key)) {
913
+ inferredKeys.add(key);
914
+ const label = key.replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase()).trim();
915
+ dataBearingFields.push({
916
+ fieldKey: key,
917
+ label,
918
+ elementRole: 'INPUT_TEXT',
919
+ dataBearing: true,
920
+ dataType: inferDataTypeFromFieldKey(key),
921
+ required: false,
922
+ piiClass: 'none',
923
+ exampleValue: '',
924
+ });
925
+ }
926
+ }
927
+ }
928
+ // Always extract login user and start URL from preconditions, regardless of whether
929
+ // FZE fields were found — deduplicate via existingKeys to avoid double-adding.
930
+ {
931
+ const precond = String(testCase.preconditions ?? '');
932
+ const stepTexts = (0, step_utils_js_1.normalizeSteps)(testCase.steps);
933
+ const allText = [precond, ...stepTexts].join('\n');
934
+ const existingKeysPrec = new Set(dataBearingFields.map((f) => f.fieldKey));
935
+ // Extract login user from "Login as: <username>" pattern in preconditions
936
+ const loginMatch = precond.match(/login as:\s*([^\s;,]+)/i);
937
+ if (loginMatch?.[1] && !existingKeysPrec.has('username')) {
938
+ const loginUser = loginMatch[1].trim();
939
+ const isEnvRef = /^\{\{\s*fze\.env\./i.test(loginUser);
940
+ existingKeysPrec.add('username');
941
+ dataBearingFields.push({
942
+ fieldKey: 'username',
943
+ label: 'Username',
944
+ elementRole: 'INPUT_TEXT',
945
+ dataBearing: true,
946
+ dataType: 'string',
947
+ required: true,
948
+ piiClass: 'none',
949
+ exampleValue: loginUser,
950
+ isEnvVarReference: isEnvRef,
951
+ });
952
+ }
953
+ // Extract start URL from preconditions "Start URL: <url>" or first navigate step
954
+ const urlFromPrec = precond.match(/start url:\s*(https?:\/\/[^\s;,]+)/i);
955
+ const allNavMatches = [...allText.matchAll(/navigate to:\s*(https?:\/\/[^\s\n"']+|\{\{\s*fze\.env\.[^}]+\}\})/gi)];
956
+ const urlFromStep = allNavMatches[0];
957
+ const _rawExtractedUrl = urlFromPrec?.[1] ?? urlFromStep?.[1] ?? '';
958
+ const extractedUrl = /^\{\{\s*fze\.env\./i.test(_rawExtractedUrl) ? _rawExtractedUrl : _rawExtractedUrl.replace(/[.,;)'"\]]+$/, '');
959
+ if (extractedUrl && !existingKeysPrec.has('startUrl')) {
960
+ existingKeysPrec.add('startUrl');
961
+ dataBearingFields.push({
962
+ fieldKey: 'startUrl',
963
+ label: 'Start URL',
964
+ elementRole: 'INPUT_TEXT',
965
+ dataBearing: true,
966
+ dataType: 'url',
967
+ required: false,
968
+ piiClass: 'none',
969
+ exampleValue: extractedUrl,
970
+ });
971
+ }
972
+ // Extract additional navigation URLs from steps (beyond startUrl).
973
+ // Captures "Navigate to: https://..." steps that represent distinct app pages visited.
974
+ function _normalizeNavUrl(url) {
975
+ if (/^\{\{/.test(url))
976
+ return url; // env var — don't normalize
977
+ try {
978
+ const u = new URL(url);
979
+ u.pathname = u.pathname.replace(/\/$/, '');
980
+ const params = [...u.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b));
981
+ u.search = '';
982
+ params.forEach(([k, v]) => u.searchParams.append(k, v));
983
+ return u.toString();
984
+ }
985
+ catch {
986
+ return url;
987
+ }
988
+ }
989
+ function _urlToSemanticKey(url, idx) {
990
+ if (/^\{\{/.test(url))
991
+ return `envUrl_${idx}`; // env var ref
992
+ try {
993
+ const u = new URL(url);
994
+ const segments = u.pathname.split('/').filter(Boolean);
995
+ const lastSeg = segments[segments.length - 1] ?? '';
996
+ const pageName = lastSeg.replace(/\.(asp|aspx|html|htm|php|jsp|do)$/i, '');
997
+ if (pageName && pageName.length >= 3 && pageName.length <= 30 && /^[a-zA-Z]/.test(pageName)) {
998
+ const camel = pageName.replace(/[-_](.)/g, (_, c) => c.toUpperCase());
999
+ return `${camel}Url`;
1000
+ }
1001
+ }
1002
+ catch { /* ignore */ }
1003
+ return `navUrl_${idx}`;
1004
+ }
1005
+ const seenNavUrls = new Set([_normalizeNavUrl(extractedUrl)].filter(Boolean));
1006
+ let navIdx = 2;
1007
+ for (const navMatch of allNavMatches.slice(1)) {
1008
+ const rawNavUrl = navMatch[1] ?? '';
1009
+ const url = /^\{\{\s*fze\.env\./i.test(rawNavUrl) ? rawNavUrl : rawNavUrl.replace(/[.,;)'"\]]+$/, '').trim();
1010
+ if (!url)
1011
+ continue;
1012
+ const normalizedUrl = _normalizeNavUrl(url);
1013
+ if (seenNavUrls.has(normalizedUrl))
1014
+ continue;
1015
+ seenNavUrls.add(normalizedUrl);
1016
+ let fieldKey = _urlToSemanticKey(url, navIdx);
1017
+ // Ensure uniqueness
1018
+ let _uniqueSuffix = 2;
1019
+ while (existingKeysPrec.has(fieldKey)) {
1020
+ fieldKey = `${_urlToSemanticKey(url, navIdx)}_${_uniqueSuffix++}`;
1021
+ }
1022
+ existingKeysPrec.add(fieldKey);
1023
+ dataBearingFields.push({
1024
+ fieldKey,
1025
+ label: `Navigation URL ${navIdx}`,
1026
+ elementRole: 'INPUT_TEXT',
1027
+ dataBearing: true,
1028
+ dataType: 'url',
1029
+ required: false,
1030
+ piiClass: 'none',
1031
+ exampleValue: url,
1032
+ });
1033
+ navIdx++;
1034
+ if (navIdx > 6)
1035
+ break; // cap at 5 additional nav URLs to avoid clutter
1036
+ }
1037
+ }
1038
+ // Extract quoted field values from step action/expected text not already covered by testDataInline.
1039
+ // Patterns: Enter "X" in/into Y, Select "X" from/in Y, Type "X" in Y.
1040
+ {
1041
+ const stepTexts = [];
1042
+ const rawSteps = testCase.steps;
1043
+ if (Array.isArray(rawSteps)) {
1044
+ for (const s of rawSteps) {
1045
+ if (s?.action)
1046
+ stepTexts.push(String(s.action));
1047
+ if (s?.expected)
1048
+ stepTexts.push(String(s.expected));
1049
+ }
1050
+ }
1051
+ const stepFieldKeys = new Set(dataBearingFields.map(f => f.fieldKey));
1052
+ // Date range: "dd/mm/yyyy - dd/mm/yyyy" or "mm/dd/yyyy - mm/dd/yyyy"
1053
+ const dateRangeRe = /(\d{1,2}\/\d{1,2}\/\d{2,4})\s*[-–to]+\s*(\d{1,2}\/\d{1,2}\/\d{2,4})/;
1054
+ if (!stepFieldKeys.has('dateRange')) {
1055
+ for (const txt of stepTexts) {
1056
+ const m = txt.match(dateRangeRe);
1057
+ if (m) {
1058
+ stepFieldKeys.add('dateRange');
1059
+ dataBearingFields.push({
1060
+ fieldKey: 'dateRange',
1061
+ label: 'Date Range',
1062
+ elementRole: 'INPUT_DATE',
1063
+ dataBearing: true,
1064
+ dataType: 'date',
1065
+ required: false,
1066
+ piiClass: 'none',
1067
+ exampleValue: `${m[1]} - ${m[2]}`,
1068
+ });
1069
+ break;
1070
+ }
1071
+ }
1072
+ }
1073
+ // Quoted field values: Enter/Type "VALUE" in/into/for FIELD_NAME (double and single quotes)
1074
+ const enterRe = /(?:enter|type|input|fill)\s+"([^"]{1,120})"\s+(?:in(?:to)?|for)\s+([^.\n]{2,60})/gi;
1075
+ const enterReSingle = /(?:enter|type|input|fill)\s+'([^']{1,120})'\s+(?:in(?:to)?|for)\s+([^.\n]{2,60})/gi;
1076
+ // Select "VALUE" from/in/for FIELD_NAME (double and single quotes)
1077
+ const selectRe = /(?:select|choose|pick)\s+"([^"]{1,120})"\s+(?:from|in|for)\s+([^.\n]{2,60})/gi;
1078
+ const selectReSingle = /(?:select|choose|pick)\s+'([^']{1,120})'\s+(?:from|in|for)\s+([^.\n]{2,60})/gi;
1079
+ // Random/dynamic placeholders from humanizeFzeToken: [random X-char text], [random email @dom], etc.
1080
+ const HUMANIZED_DYNAMIC_RE = /^\[random\b|^\[dynamic\]|^\[env:/i;
1081
+ // Values already captured from dataBearingFields (AppField catalog) AND from testDataInline directly.
1082
+ // When AppField records exist the inline synthesis block (line ~596) is skipped, so inlineData values
1083
+ // never enter dataBearingFields — include them here so step-text fields don't duplicate inline data.
1084
+ const _inlineConcreteValues = Object.values(inlineData ?? {})
1085
+ .filter((v) => typeof v === 'string' && v.trim().length > 0 && !/^\{\{fze\./i.test(v.trim()) && !HUMANIZED_DYNAMIC_RE.test(v.trim()))
1086
+ .map(v => v.trim());
1087
+ const seenValues = new Set([
1088
+ ...dataBearingFields.map(f => String(f.exampleValue ?? '').trim()).filter(v => v.length > 0),
1089
+ ..._inlineConcreteValues,
1090
+ ]);
1091
+ for (const txt of stepTexts) {
1092
+ for (const re of [enterRe, enterReSingle, selectRe, selectReSingle]) {
1093
+ re.lastIndex = 0;
1094
+ let m;
1095
+ while ((m = re.exec(txt)) !== null) {
1096
+ const value = m[1].trim();
1097
+ const rawField = m[2].trim().replace(/[^\w\s]/g, '').trim();
1098
+ const fieldKey = rawField.replace(/\s+(.)/g, (_, c) => c.toUpperCase()).replace(/^./, (c) => c.toLowerCase()).replace(/\W/g, '').slice(0, 40);
1099
+ if (!fieldKey || stepFieldKeys.has(fieldKey) || value.length === 0)
1100
+ continue;
1101
+ // Skip if the same concrete value already exists under a different field name.
1102
+ // Prevents szZip=10008 and zipField=10008 from both appearing when both
1103
+ // testDataInline key and step-text-derived key represent the same field.
1104
+ if (!HUMANIZED_DYNAMIC_RE.test(value) && seenValues.has(value))
1105
+ continue;
1106
+ // Dynamic placeholders become [dynamic] hints — don't use them as example values.
1107
+ const isDynPlaceholder = HUMANIZED_DYNAMIC_RE.test(value);
1108
+ stepFieldKeys.add(fieldKey);
1109
+ seenValues.add(value);
1110
+ const label = rawField.replace(/^./, (c) => c.toUpperCase());
1111
+ dataBearingFields.push({
1112
+ fieldKey,
1113
+ label,
1114
+ elementRole: 'INPUT_TEXT',
1115
+ dataBearing: true,
1116
+ dataType: 'string',
1117
+ required: false,
1118
+ piiClass: 'none',
1119
+ exampleValue: isDynPlaceholder ? '[dynamic]' : value.slice(0, 200),
1120
+ });
1121
+ }
1122
+ }
1123
+ }
1124
+ // Second pass: extract expected values from verification/assertion steps
1125
+ const _seenVerifyValues = new Set(dataBearingFields.map(f => String(f.exampleValue ?? '').trim()).filter(v => v.length > 0));
1126
+ const allRawStepsVerify = Array.isArray(testCase.steps) ? testCase.steps : [];
1127
+ for (const step of allRawStepsVerify) {
1128
+ const expected = step?.expected ?? step?.data?.expected ?? '';
1129
+ const action = step?.action ?? step?.description ?? '';
1130
+ if (!expected || typeof expected !== 'string')
1131
+ continue;
1132
+ const expectedTrimmed = expected.trim();
1133
+ if (!expectedTrimmed || expectedTrimmed.length < 2 || expectedTrimmed.length > 300)
1134
+ continue;
1135
+ // Skip FZE expressions
1136
+ if (/^\{\{\s*fze\./i.test(expectedTrimmed))
1137
+ continue;
1138
+ // Skip URLs (already captured as navUrl)
1139
+ if (/^https?:\/\//i.test(expectedTrimmed))
1140
+ continue;
1141
+ // Normalize multi-select raw values instead of skipping — extract human-readable options
1142
+ const _normalizedExpected = (expectedTrimmed.startsWith('OR|FUNCOPR') || expectedTrimmed.includes('|FUNCVAL|'))
1143
+ ? normalizeFzeMultiSelectValue(expectedTrimmed)
1144
+ : expectedTrimmed;
1145
+ // Skip if normalization was a no-op (i.e., the string still looks like raw FZE encoding)
1146
+ if (_normalizedExpected.startsWith('OR|FUNCOPR') || _normalizedExpected.includes('|FUNCVAL|'))
1147
+ continue;
1148
+ // Skip if already captured as a data value
1149
+ if (_seenVerifyValues.has(_normalizedExpected))
1150
+ continue;
1151
+ // Derive a fieldKey from the action type
1152
+ const actionLower = action.toLowerCase();
1153
+ let fieldKey = '';
1154
+ if (/verify.*url|url.*contain/i.test(actionLower)) {
1155
+ fieldKey = 'expectedUrl';
1156
+ }
1157
+ else if (/verify.*text|check.*text/i.test(actionLower)) {
1158
+ const words = action.replace(/[^a-zA-Z ]/g, '').trim().split(/\s+/).slice(2, 5);
1159
+ fieldKey = words.map((w, i) => i === 0 ? w.toLowerCase() : w[0].toUpperCase() + w.slice(1)).join('') + 'Expected';
1160
+ }
1161
+ else {
1162
+ fieldKey = 'verifyExpected';
1163
+ }
1164
+ if (!fieldKey)
1165
+ fieldKey = 'verifyExpected';
1166
+ // Ensure unique key
1167
+ let uniqueKey = fieldKey;
1168
+ let _vIdx = 2;
1169
+ while (stepFieldKeys.has(uniqueKey)) {
1170
+ uniqueKey = `${fieldKey}_${_vIdx++}`;
1171
+ }
1172
+ stepFieldKeys.add(uniqueKey);
1173
+ _seenVerifyValues.add(_normalizedExpected);
1174
+ dataBearingFields.push({
1175
+ fieldKey: uniqueKey,
1176
+ label: `Expected: ${_normalizedExpected.slice(0, 40)}`,
1177
+ elementRole: 'INPUT_TEXT',
1178
+ dataBearing: true,
1179
+ dataType: 'string',
1180
+ required: false,
1181
+ piiClass: 'none',
1182
+ exampleValue: _normalizedExpected,
1183
+ });
1184
+ }
1185
+ }
1186
+ // Extract click targets that may be dynamic (non-static labels like report names, entity names).
1187
+ // Skips generic static labels ("Save", "Cancel", etc.) — only captures substantive click targets.
1188
+ {
1189
+ const stepTextsClick = [];
1190
+ const rawStepsClick = testCase.steps;
1191
+ if (Array.isArray(rawStepsClick)) {
1192
+ for (const s of rawStepsClick) {
1193
+ if (s?.action)
1194
+ stepTextsClick.push(String(s.action));
1195
+ }
1196
+ }
1197
+ const allClickText = stepTextsClick.join('\n');
1198
+ const clickFieldKeys = new Set(dataBearingFields.map(f => f.fieldKey));
1199
+ const clickSeenValues = new Set(dataBearingFields.map(f => String(f.exampleValue ?? '').trim()).filter(v => v.length > 0));
1200
+ const STATIC_CLICK_TARGETS = new Set([
1201
+ 'save', 'cancel', 'close', 'ok', 'yes', 'no', 'submit', 'back', 'next',
1202
+ 'continue', 'sign in', 'login', 'logout', 'select', 'add', 'delete',
1203
+ 'remove', 'edit', 'update', 'confirm', 'apply', 'reset', 'clear', 'done',
1204
+ ]);
1205
+ const clickPatterns = [
1206
+ /(?:click|tap|press)\s+(?:the\s+)?["']([^"']{2,80})["']/gi,
1207
+ /click\s+(?:button|link|tab|menu)[:\s]+["']([^"']{2,80})["']/gi,
1208
+ ];
1209
+ for (const pattern of clickPatterns) {
1210
+ pattern.lastIndex = 0;
1211
+ let cm;
1212
+ while ((cm = pattern.exec(allClickText)) !== null) {
1213
+ const target = cm[1]?.trim();
1214
+ if (!target || target.length < 2)
1215
+ continue;
1216
+ if (STATIC_CLICK_TARGETS.has(target.toLowerCase()))
1217
+ continue;
1218
+ if (/^https?:\/\//.test(target))
1219
+ continue;
1220
+ if (clickSeenValues.has(target))
1221
+ continue;
1222
+ const fieldKey = 'clickTarget_' + target.replace(/[^a-zA-Z0-9]/g, '').slice(0, 20);
1223
+ if (clickFieldKeys.has(fieldKey))
1224
+ continue;
1225
+ clickFieldKeys.add(fieldKey);
1226
+ clickSeenValues.add(target);
1227
+ dataBearingFields.push({
1228
+ fieldKey,
1229
+ label: `Click target: ${target}`,
1230
+ elementRole: 'INPUT_TEXT',
1231
+ dataBearing: true,
1232
+ dataType: 'string',
1233
+ required: false,
1234
+ piiClass: 'none',
1235
+ exampleValue: target,
1236
+ });
1237
+ }
1238
+ }
1239
+ }
1240
+ // Remove alias fields from dataBearingFields before they reach the LLM.
1241
+ // Step-text regex loop (above) adds zipField/typeField/commentsField etc. without checking seenValues.
1242
+ // Those alias fields carry the same value as the real DB-backed field (szZip/szType/szComments),
1243
+ // so the LLM would generate both szZip AND zipField in output.
1244
+ {
1245
+ const _dedup = new Set();
1246
+ dataBearingFields = dataBearingFields.filter(f => {
1247
+ const v = String(f.exampleValue ?? '').trim();
1248
+ // Only dedup by value when value is substantial and not fze/dynamic
1249
+ if (!v || v.length >= 300 || /^\{\{\s*fze\./i.test(v) || v.startsWith('dynamic') || v.startsWith('http'))
1250
+ return true;
1251
+ if (_dedup.has(v))
1252
+ return false;
1253
+ _dedup.add(v);
1254
+ return true;
1255
+ });
1256
+ }
1257
+ // Final canonical key dedup pass: remove alias fields where a canonical equivalent already exists.
1258
+ // Handles cases like szFirstName vs firstNameField (both canonicalize to 'firstname') that survive
1259
+ // the value-dedup above because they may carry different exampleValues across extraction passes.
1260
+ // Prefer FZE action keys (sz/txt/list prefix) over generated aliases (Field/Input suffix).
1261
+ {
1262
+ function _getCanonKey(key) {
1263
+ return key
1264
+ .replace(/^(sz|txt|list|lbl|btn|chk|l|n)(?=[A-Z])/i, '')
1265
+ .replace(/^(.)/, (_, c) => c.toLowerCase())
1266
+ .replace(/(Field|Input|Select|Dropdown|Button|Text|Value)$/i, '')
1267
+ .toLowerCase();
1268
+ }
1269
+ const _canonToPreferred = new Map(); // canonical → preferred fieldKey
1270
+ for (const field of dataBearingFields) {
1271
+ const canon = _getCanonKey(field.fieldKey);
1272
+ const existing = _canonToPreferred.get(canon);
1273
+ if (!existing) {
1274
+ _canonToPreferred.set(canon, field.fieldKey);
1275
+ }
1276
+ else {
1277
+ // Prefer FZE action keys (sz/txt/list prefix) over generated aliases
1278
+ const existingIsFze = /^(sz|txt|list|lbl|btn|chk|l|n)[A-Z]/.test(existing);
1279
+ const currentIsFze = /^(sz|txt|list|lbl|btn|chk|l|n)[A-Z]/.test(field.fieldKey);
1280
+ if (currentIsFze && !existingIsFze) {
1281
+ _canonToPreferred.set(canon, field.fieldKey);
1282
+ }
1283
+ }
1284
+ }
1285
+ dataBearingFields = dataBearingFields.filter(field => {
1286
+ const canon = _getCanonKey(field.fieldKey);
1287
+ return _canonToPreferred.get(canon) === field.fieldKey;
1288
+ });
1289
+ }
1290
+ // Collect actual step-recorded values for deterministic VALID dataset injection.
1291
+ // Like locator extraction — real recorded values, no LLM fabrication needed.
1292
+ // Value-dedup: skip alias fields that carry the same value as a field already added
1293
+ // (e.g. zipField≈szZip, typeField≈szType — same value, different key → drop the alias).
1294
+ const directExtractedValues = {};
1295
+ const _seenDirectValues = new Set();
1296
+ for (const f of dataBearingFields) {
1297
+ const v = String(f.exampleValue ?? '').trim();
1298
+ // Password fields: always include with '[protected]' sentinel — value-dedup skipped since
1299
+ // '[protected]' is not a real value but a masked placeholder that must appear in the row.
1300
+ if (v === '[protected]') {
1301
+ directExtractedValues[f.fieldKey] = '[protected]';
1302
+ continue;
1303
+ }
1304
+ if (v && !v.startsWith('dynamic') && v !== 'Test@123!' && v.length < 300 && !/^\{\{\s*fze\.(random|local)\./i.test(v)) {
1305
+ if (_seenDirectValues.has(v))
1306
+ continue;
1307
+ _seenDirectValues.add(v);
1308
+ directExtractedValues[f.fieldKey] = v;
1309
+ }
1310
+ }
1311
+ const mode = dataBearingFields.length > 0 ? 'form' : 'state';
1312
+ const maxDatasets = contextualMax(dataBearingFields, kind, mode);
1313
+ const screenMarkdown = mode === 'state' && testCase.screenId
1314
+ ? await app_brain_1.AppBrain.getScreenMarkdown(opts.tenantId, testCase.screenId).then((r) => r?.markdownContent ?? undefined).catch(() => undefined)
1315
+ : undefined;
1316
+ const { system: systemDefault, user } = mode === 'form'
1317
+ ? buildFormPrompt({ testCase, domainCtx, fields: dataBearingFields, groundedOn, inlineData, kind, relatedCases: projectCases, maxDatasets, dataNote, structuredSteps })
1318
+ : buildStatePrompt({ testCase, domainCtx, groundedOn, inlineData, kind, relatedCases: projectCases, maxDatasets, dataNote, structuredSteps, screenMarkdown });
1319
+ const dataPromptKey = mode === 'form' ? 'data-gen-values' : 'data-gen-schema';
1320
+ const system = await (0, core_1.getAgentPrompt)(dataPromptKey, undefined, opts.tenantId).catch(() => null) ?? systemDefault;
1321
+ // Include field count in seed — cache misses when extraction improves and more fields found.
1322
+ const _respSeedNum = (0, llm_js_1.deterministicSeed)(`${opts.tenantId}:${opts.projectId}:${opts.testCaseId}:form:${kind}:f${dataBearingFields.length}`);
1323
+ const _respSeedKey = String(_respSeedNum);
1324
+ let dataSets;
1325
+ let source = 'SYNTHETIC';
1326
+ // Check seed-keyed cache — L1 (in-process) then L2 (Redis) — skip LLM call entirely on hit
1327
+ const _cachedRespL1 = _respCacheGet(_respSeedKey);
1328
+ const _cachedRespL2 = _cachedRespL1 ? undefined : await _respCacheGetL2(_respSeedKey);
1329
+ const _cachedResp = _cachedRespL1 ?? _cachedRespL2;
1330
+ if (_cachedResp) {
1331
+ // Warm L1 from Redis hit so next request on this pod is zero-latency
1332
+ if (_cachedRespL2)
1333
+ _respCacheSet(_respSeedKey, _cachedResp);
1334
+ dataSets = _cachedResp;
1335
+ }
1336
+ else {
1337
+ let _rawLlmResponse;
1338
+ try {
1339
+ const t0 = Date.now();
1340
+ const { text: raw, usage } = await (0, llm_config_js_1.tryWithPlatformFallback)(llmCfg, (cfg) => (0, llm_js_1.askTracked)(system, user, cfg, 4096, DATA_SETS_SCHEMA, { temperature: 0, seed: _respSeedNum }));
1341
+ _rawLlmResponse = raw;
1342
+ const latencyMs = Date.now() - t0;
1343
+ const parsed = extractJson(raw);
1344
+ // State mode may return interaction variations instead of datasets
1345
+ if (parsed.interactionOnly && mode === 'form') {
1346
+ throw new Error('LLM returned interactionOnly but mode is form — treating as fallback');
1347
+ }
1348
+ dataSets = (parsed.dataSets ?? []).map((ds) => ({
1349
+ label: ds.label ?? 'Dataset',
1350
+ category: ds.category ?? 'VALID',
1351
+ values: ds.values ?? {},
1352
+ expectedOutcome: ds.expectedOutcome ?? '',
1353
+ ...(ds.gherkinExample ? { gherkinExample: ds.gherkinExample } : {}),
1354
+ }));
1355
+ // Enforce: only allowed fieldKeys in values{}; enforce PII=high synthetic
1356
+ if (mode === 'form') {
1357
+ const allowedKeys = new Set(dataBearingFields.map((f) => f.fieldKey));
1358
+ dataSets = enforceFieldKeys(dataSets, allowedKeys, dataBearingFields);
1359
+ // Post-parse constraint enforcement: clamp numerics, fix enums, truncate strings
1360
+ dataSets = clampConstrainedValues(dataSets, dataBearingFields);
1361
+ }
1362
+ // Reject hedged expected outcomes
1363
+ dataSets = rejectHedgedOutcomes(dataSets);
1364
+ // Warn: static email/username/phone in non-VALID rows (should use DYNAMIC factory)
1365
+ if (mode === 'form') {
1366
+ const EMAIL_USERNAME_STATIC_RE = /["'][a-zA-Z0-9._%+]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}["']/;
1367
+ dataSets = dataSets.map(ds => {
1368
+ if (ds.label !== 'valid' && ds.label !== 'recorded' && ds.category !== 'VALID') {
1369
+ const keys = Object.keys(ds.values ?? {}).filter(k => /email|username|phone/i.test(k));
1370
+ for (const k of keys) {
1371
+ if (typeof ds.values[k] === 'string' && EMAIL_USERNAME_STATIC_RE.test(JSON.stringify(ds.values[k]))) {
1372
+ console.warn(`[data-agent] static ${k} in non-valid row "${ds.label}" — should use DYNAMIC factory`);
1373
+ ds._warnings = [...(ds._warnings ?? []), `static ${k} in non-valid row — use DYNAMIC factory`];
1374
+ }
1375
+ }
1376
+ }
1377
+ return ds;
1378
+ });
1379
+ }
1380
+ // Canonicalize STATE mode values to consistent formats before intent-scope filtering
1381
+ if (mode === 'state')
1382
+ dataSets = canonicalizeStateValues(dataSets);
1383
+ dataSets = enforceIntentScope(dataSets, testCase, kind, mode);
1384
+ // Guarantee minimum — supplement with fallback if LLM returned fewer than DATA_MIN datasets.
1385
+ // Category-aware: fill MISSING categories first, then fill remaining slots.
1386
+ // IMPORTANT: filter fallback pool through the same intent scope so E2E/positive tests
1387
+ // don't get INVALID rows re-injected after enforceIntentScope already removed them.
1388
+ if (dataSets.length < DATA_MIN) {
1389
+ const rawFallbackPool = fallbackDataSets(kind, mode, dataBearingFields.length > 0 ? dataBearingFields : fields);
1390
+ const fallbackPool = enforceIntentScope(rawFallbackPool, testCase, kind, mode);
1391
+ const existingSigs = new Set(dataSets.map((d) => `${d.category}|${JSON.stringify(d.values)}`));
1392
+ const existingCategories = new Set(dataSets.map((d) => d.category));
1393
+ // Pass 1: fill categories not yet represented (e.g. INVALID missing → add INVALID fallback)
1394
+ for (const fb of fallbackPool) {
1395
+ if (dataSets.length >= DATA_MIN)
1396
+ break;
1397
+ if (!existingCategories.has(fb.category)) {
1398
+ const sig = `${fb.category}|${JSON.stringify(fb.values)}`;
1399
+ if (!existingSigs.has(sig)) {
1400
+ dataSets.push(fb);
1401
+ existingSigs.add(sig);
1402
+ existingCategories.add(fb.category);
1403
+ }
1404
+ }
1405
+ }
1406
+ // Pass 2: fill remaining slots from any category (still deduped by signature)
1407
+ for (const fb of fallbackPool) {
1408
+ if (dataSets.length >= DATA_MIN)
1409
+ break;
1410
+ const sig = `${fb.category}|${JSON.stringify(fb.values)}`;
1411
+ if (!existingSigs.has(sig)) {
1412
+ dataSets.push(fb);
1413
+ existingSigs.add(sig);
1414
+ }
1415
+ }
1416
+ }
1417
+ dataSets = normalizeTargetCount(dataSets, opts.targetCount, enforceIntentScope(fallbackDataSets(kind, mode, dataBearingFields.length > 0 ? dataBearingFields : fields), testCase, kind, mode));
1418
+ // Dedup by label AFTER guarantee-min and normalizeTargetCount (those can re-add same labels).
1419
+ // Keep first occurrence of each label.
1420
+ const seenLabels = new Set();
1421
+ dataSets = dataSets.filter(ds => {
1422
+ const l = String(ds.label ?? '').toLowerCase().trim();
1423
+ if (seenLabels.has(l))
1424
+ return false;
1425
+ seenLabels.add(l);
1426
+ return true;
1427
+ });
1428
+ // Strip fze templates from values, expectedOutcome, and gherkinExample.
1429
+ // Use non-global regex for .test() — global flag causes alternating true/false on repeated calls.
1430
+ const FZE_RE = /\{\{\s*fze\.(random|local)\.[^}]+\}\}/i;
1431
+ const FZE_RE_G = /\{\{\s*fze\.(random|local)\.[^}]+\}\}/gi;
1432
+ dataSets = dataSets.map(ds => ({
1433
+ ...ds,
1434
+ values: ds.values
1435
+ ? Object.fromEntries(Object.entries(ds.values).filter(([, v]) => !FZE_RE.test(String(v ?? ''))))
1436
+ : ds.values,
1437
+ expectedOutcome: typeof ds.expectedOutcome === 'string'
1438
+ ? ds.expectedOutcome.replace(FZE_RE_G, '[dynamic]')
1439
+ : ds.expectedOutcome,
1440
+ gherkinExample: typeof ds.gherkinExample === 'string'
1441
+ ? ds.gherkinExample.replace(FZE_RE_G, '[dynamic]')
1442
+ : ds.gherkinExample,
1443
+ }));
1444
+ _respCacheSet(_respSeedKey, dataSets);
1445
+ _respCacheSetL2(_respSeedKey, dataSets).catch(console.error);
1446
+ (0, app_brain_1.recordLLMCall)({
1447
+ tenantId: opts.tenantId,
1448
+ projectId: opts.projectId,
1449
+ jobId: opts.jobId,
1450
+ agentType: 'data',
1451
+ label: `test_data_${kind}_${mode}`,
1452
+ entityId: opts.testCaseId,
1453
+ entityType: 'test_data',
1454
+ usage,
1455
+ latencyMs,
1456
+ }).catch(console.error);
1457
+ }
1458
+ catch (err) {
1459
+ console.error('[data-agent] LLM call failed, using fallback:', err instanceof Error ? err.message : err);
1460
+ if (_rawLlmResponse)
1461
+ console.error('[data-agent] raw LLM response (first 600):', _rawLlmResponse.slice(0, 600));
1462
+ dataSets = enforceIntentScope(fallbackDataSets(kind, mode, dataBearingFields.length > 0 ? dataBearingFields : fields), testCase, kind, mode);
1463
+ dataSets = normalizeTargetCount(dataSets, opts.targetCount, dataSets);
1464
+ source = 'FALLBACK';
1465
+ }
1466
+ } // end else (cache miss)
1467
+ // Inject step-extracted VALID dataset as first row — ensures actual recorded values
1468
+ // always appear in output, regardless of LLM generation or intent-scope filtering.
1469
+ // This mirrors locator extraction: deterministic, from real test steps, no LLM.
1470
+ if (Object.keys(directExtractedValues).length > 0 && mode === 'form') {
1471
+ const directSig = JSON.stringify(directExtractedValues);
1472
+ const alreadyPresent = dataSets.some(d => d.category === 'VALID' && JSON.stringify(d.values) === directSig);
1473
+ if (!alreadyPresent) {
1474
+ dataSets.unshift({
1475
+ label: 'Recorded from test steps',
1476
+ category: 'VALID',
1477
+ values: directExtractedValues,
1478
+ expectedOutcome: 'All recorded inputs accepted; test flow completes as specified.',
1479
+ });
1480
+ }
1481
+ }
1482
+ // Persist with source tag (fallback rows tagged separately)
1483
+ const saved = await app_brain_1.AppBrain.saveTestData(opts.tenantId, opts.projectId, opts.testCaseId, dataSets.map((ds) => ({ ...ds, source })));
1484
+ // Fix C: For synthetic FORM mode (imported cases — groundedOn empty), link test case to
1485
+ // a shared TestDataSet keyed by field fingerprint so all cases sharing the same field set
1486
+ // reuse one dataset instead of generating duplicate rows.
1487
+ if (mode === 'form' && !groundedOn.length && dataBearingFields.length > 0) {
1488
+ await app_brain_1.AppBrain.linkOrCreateSharedDataSet(opts.tenantId, opts.projectId, opts.testCaseId, dataBearingFields.map((f) => f.fieldKey), dataSets).catch((e) => console.warn('[data-agent] shared dataset link failed:', e?.message));
1489
+ }
1490
+ return { dataSets, saved, mode, source };
1491
+ }
1492
+ // ─── PERSONA LIBRARY ───────────────────────────────────────────────────────────
1493
+ // Real QA teams think in archetypes. Each persona represents a distinct user type
1494
+ // a tester would naturally cover. Including these in the prompt produces diverse,
1495
+ // realistic datasets instead of random variations of the same value.
1496
+ const PERSONAS = [
1497
+ { role: 'Admin', name: 'Sarah Chen', email: 'sarah.chen@{domain}', note: 'Power user with full access' },
1498
+ { role: 'Manager', name: 'James Okafor', email: 'james.okafor@{domain}', note: 'Department head, approver role' },
1499
+ { role: 'Standard User', name: 'Maria Garcia', email: 'maria.garcia@{domain}', note: 'Regular employee, day-to-day tasks' },
1500
+ { role: 'New User', name: 'Tyler Brooks', email: 'tyler.brooks@{domain}', note: 'Onboarded this week, incomplete profile' },
1501
+ { role: 'Read-Only', name: 'Priya Nair', email: 'priya.nair@{domain}', note: 'Viewer only, no write permissions' },
1502
+ { role: 'External Partner', name: 'Luca Ferrari', email: 'luca.ferrari@partner.com', note: 'Third-party contractor with limited scope' },
1503
+ ];
1504
+ function buildPersonaBlock(fields, domainCtx) {
1505
+ const hasEmail = fields.some((f) => f.dataType === 'email');
1506
+ const hasUsername = fields.some((f) => f.fieldKey.toLowerCase().includes('user') || f.fieldKey.toLowerCase().includes('name'));
1507
+ const hasRole = fields.some((f) => f.fieldKey.toLowerCase().includes('role') || f.dataType === 'enum');
1508
+ if (!hasEmail && !hasUsername && !hasRole)
1509
+ return '';
1510
+ // Try to extract domain from domainCtx; fallback to startUrl field if skill file has no URL
1511
+ const domainMatch = domainCtx.match(/https?:\/\/([^/\s"']+)/);
1512
+ const startUrlField = fields.find((f) => f.fieldKey === 'startUrl' && f.exampleValue);
1513
+ const urlFallbackMatch = startUrlField?.exampleValue?.match(/https?:\/\/([^/\s"']+)/);
1514
+ const rawDomain = domainMatch?.[1] ?? urlFallbackMatch?.[1] ?? null;
1515
+ const domain = rawDomain?.replace(/^(staging\.|dev\.|uat\.)/, '') ?? 'company.com';
1516
+ const lines = [
1517
+ 'USER ARCHETYPES — derive diverse realistic values from these personas instead of inventing generic test data:',
1518
+ ...PERSONAS.slice(0, 4).map((p) => ` ${p.role}: name="${p.name}", email="${p.email.replace('{domain}', domain)}" — ${p.note}`),
1519
+ 'Use these as inspiration — vary the values per dataset to represent different user contexts.',
1520
+ ];
1521
+ return lines.join('\n');
1522
+ }
1523
+ // ─── FORM MODE ─────────────────────────────────────────────────────────────────
1524
+ function buildFormPrompt(opts) {
1525
+ const { testCase, domainCtx, fields, groundedOn, inlineData, kind, relatedCases, maxDatasets, dataNote, structuredSteps } = opts;
1526
+ const fieldKeys = fields.map((f) => f.fieldKey);
1527
+ const hasSecurityTargets = fields.some(allowsSecurityProbe);
1528
+ const system = [
1529
+ 'You generate test DATA for a single test case in a QA platform.',
1530
+ '',
1531
+ 'OUTPUT FORMAT:',
1532
+ '{ "dataSets": [ { "label": string, "category": "VALID"|"INVALID"|"EDGE"|"SECURITY", "values": { fieldKey: value }, "expectedOutcome": string, "gherkinExample": string } ] }',
1533
+ '',
1534
+ 'HARD RULES — follow exactly:',
1535
+ ` 1. Keys in "values" MUST be from this exact set: [${fieldKeys.join(', ')}]. Never invent new keys.`,
1536
+ ' 2. Every expectedOutcome must be DETERMINISTIC. No hedging: "possibly", "potentially", "may", "might", "should probably", "could" are forbidden.',
1537
+ ' 3. For every INVALID dataset, state the exact rule violated and the exact error text the UI shows.',
1538
+ ' 4. SECURITY probes (XSS, SQLi, path traversal, template injection) ONLY for fields marked SECURITY below.',
1539
+ ` ${hasSecurityTargets ? 'Security-eligible fields exist — generate SECURITY datasets for them.' : 'NO security-eligible fields — do NOT generate any SECURITY datasets.'}`,
1540
+ ' 5. PII-class high fields: use obviously-synthetic values (e.g. "Test User", "0000 0000 0000") — never real-looking SSNs, card numbers, passwords, or healthcare IDs.',
1541
+ ' 6. gherkinExample: pipe-delimited Examples row matching the field keys as headers.',
1542
+ ' 7. Output valid JSON only. No preamble, no markdown.',
1543
+ ' 8. CRITICAL — example values ARE real: The "example:" value shown for each field below is the EXACT real value recorded from the application under test. Your first VALID dataset MUST use every available example value verbatim. NEVER substitute with generic placeholders like "validUser", "testUser", "user@example.com", "example.com", "app.example.com", "test@example.com", "http://example.com", or any invented domain/username. Real values only.',
1544
+ ' 9. CRITICAL — this is NOT a suite-level coverage exercise. Generate data ONLY for this one test case. Do not cover sibling scenarios, generic validation cases, empty input, no-results cases, first-use cases, rapid repeated actions, multi-device cases, or security probes unless the TEST CASE title/steps/expected result explicitly ask for that scenario.',
1545
+ ' 10. Field STRATEGY lines are reference material only. Do NOT exhaustively apply every strategy class. Pick only the value classes needed for this test case intent.',
1546
+ ' 11. CROSS-FIELD RULES: When multiple fields are present, ensure values are mutually consistent within each dataset. Examples: if an age field and a credit-card field both exist, all VALID datasets must have age ≥ 18; if a start-date and end-date field exist, end-date must be after start-date in VALID datasets; if a country field and phone field exist, phone format must match country. Violate exactly one cross-field rule per INVALID/EDGE dataset targeting that rule.',
1547
+ ' 12. WIZARD/MULTI-STEP CONTEXT: If this test case is for a specific step in a multi-step wizard or flow, your VALID datasets must assume all prior steps were completed successfully (preconditions met). INVALID datasets must target only validation failures specific to THIS step — do not produce data that would have been rejected in a prior step.',
1548
+ ' 13. BOUNDARY VALUES (BVA): For numeric, date, or length-constrained fields, generate at least one EDGE dataset at a boundary: minimum value, maximum value, one below minimum, or one above maximum. For date fields: earliest valid date, latest valid date. Label boundary datasets with category "EDGE".',
1549
+ ' 14. EQUIVALENCE CLASSES: Group inputs into distinct equivalence classes (e.g. valid range, below-range, above-range, empty, special-chars). Each dataset should represent a different class — not minor variants of the same class.',
1550
+ ' 15. REQUIRED: For any field with type email, username, phone, or password — you MUST include a DYNAMIC entry using a factory function (not a static string). A dataset with a static email/username/phone is only acceptable as the first VALID row (the recorded value). All other rows with email/username/phone MUST use DYNAMIC factory functions. Static emails like "test@example.com" or "user1@test.com" in INVALID/EDGE rows are FORBIDDEN — use dynamic generation.',
1551
+ '',
1552
+ `DOMAIN CONTEXT: ${domainCtx}`,
1553
+ ...(dataNote ? ['', `TEST DESIGNER GUIDANCE (follow precisely — overrides general heuristics):`, dataNote] : []),
1554
+ ].join('\n');
1555
+ // Build pairwise block for ≥3 data-bearing fields
1556
+ const pairwiseBlock = fields.length >= 3 ? buildPairwiseBlock(fields) : '';
1557
+ // Build relation-group cross-field integrity block
1558
+ const relationGroupBlock = buildRelationGroupBlock(fields);
1559
+ const exclusionsBlock = relatedCaseExclusionBlock(testCase, relatedCases, groundedOn);
1560
+ const personaBlock = buildPersonaBlock(fields, domainCtx);
1561
+ const user = [
1562
+ `TEST CASE: ${testCase.title}`,
1563
+ `TEST KIND: ${kind}`,
1564
+ '',
1565
+ 'TEST STEPS:',
1566
+ (0, step_utils_js_1.normalizeSteps)(testCase.steps).map((s, i) => `${i + 1}. ${s}`).join('\n'),
1567
+ '',
1568
+ 'EXPECTED RESULT:',
1569
+ String(testCase.expectedResult ?? ''),
1570
+ '',
1571
+ ...(exclusionsBlock ? [exclusionsBlock, ''] : []),
1572
+ ...(personaBlock ? [personaBlock, ''] : []),
1573
+ 'FIELDS — generate datasets using ONLY these keys with the strategies shown:',
1574
+ buildFieldsBlock(fields),
1575
+ '',
1576
+ `COVERAGE REQUIREMENTS:`,
1577
+ ` 1. Be COMPREHENSIVE — generate every distinct scenario a senior QA engineer would test. Minimum ${DATA_MIN} datasets; no artificial upper limit. Cover all meaningful field-value combinations, boundary values, error conditions, and representative personas.`,
1578
+ ' 2. Think like a senior QA engineer: What distinct real-world conditions would a human tester try? Use the USER ARCHETYPES above to derive realistic names, emails, and roles — not generic placeholders.',
1579
+ ' 3. Each dataset MUST have different values — not minor variants (user1/user2) but genuinely different inputs that test different paths.',
1580
+ ' 4. For POSITIVE tests, generate VALID datasets only unless the title or expected result explicitly describes a boundary/edge; do not generate INVALID rows for unrelated negative tests.',
1581
+ ` 5. FIELD COVERAGE: Every data-bearing field listed above must appear with a concrete value in at least one dataset. Do not omit form fields that appear in the test steps — they are part of the flow and must be covered.`,
1582
+ ...(hasSecurityTargets ? [' 6. SECURITY: XSS, SQLi, path-traversal for security-eligible fields'] : []),
1583
+ ...(pairwiseBlock ? ['', pairwiseBlock] : []),
1584
+ ...(relationGroupBlock ? ['', relationGroupBlock] : []),
1585
+ '',
1586
+ formKindInstructions(kind),
1587
+ ...(inlineData && Object.keys(inlineData).length > 0 ? (() => {
1588
+ // Strip metadata/artifact keys — not form field values
1589
+ const INLINE_SKIP = new Set(['customScripts', 'contextSwitches', 'recordedUrls', 'recordedSelections', 'recordedUser', 'recordedPassword']);
1590
+ const filtered = Object.fromEntries(Object.entries(inlineData).filter(([k]) => !INLINE_SKIP.has(k) && !k.startsWith('artifact_')));
1591
+ return Object.keys(filtered).length > 0 ? [
1592
+ '',
1593
+ 'REAL RECORDED VALUES (these are actual values from the application — use them verbatim in VALID datasets, not as inspiration for invented values):',
1594
+ JSON.stringify(filtered, null, 2),
1595
+ ] : [];
1596
+ })() : []),
1597
+ ...(structuredSteps && structuredSteps.length > 0 ? [
1598
+ '',
1599
+ 'RECORDED ACTIONS (machine-readable step breakdown — use "data" values as the authoritative field:value pairs):',
1600
+ structuredSteps.filter((s) => s.data).map((s) => ` ${s.target ?? s.action}: "${s.data}"`).join('\n'),
1601
+ ] : []),
1602
+ ].join('\n');
1603
+ return { system, user };
1604
+ }
1605
+ function formKindInstructions(kind) {
1606
+ switch (kind) {
1607
+ case 'NEGATIVE':
1608
+ return 'FOCUS: Every INVALID dataset must fail in a distinct way directly related to the test case intent. Include the exact error message shown by the UI in expectedOutcome.';
1609
+ case 'EDGE':
1610
+ return 'FOCUS: Boundary and edge values specific to this test case scenario.';
1611
+ case 'SECURITY':
1612
+ return 'FOCUS: Injection probe datasets only. All SECURITY category. Each expectedOutcome: "Input rejected; no sensitive detail in error response."';
1613
+ case 'ACCESSIBILITY':
1614
+ return 'FOCUS: Datasets that test screen reader / keyboard nav — label presence, empty required fields, aria state.';
1615
+ case 'E2E':
1616
+ case 'INTEGRATION':
1617
+ return 'FOCUS: Cover the FULL recorded flow. Every data-bearing field listed above must appear with a concrete value in at least one dataset. Vary credentials, form inputs, and configuration fields across datasets — do not reduce to only URL/username variations. Each dataset must represent a distinct real-world execution path through the entire flow.';
1618
+ default:
1619
+ return 'FOCUS: Generate diverse datasets that explicitly exercise the behavioral intent described in the TEST CASE title. Avoid generic exhaustive edge cases if they do not match the specific intent.';
1620
+ }
1621
+ }
1622
+ // ─── VALUE CANONICALIZATION ────────────────────────────────────────────────────
1623
+ // Normalizes STATE mode output to consistent formats regardless of how the LLM phrases them.
1624
+ // Prevents "ctaPresent: true" (fallback) vs "ctaPresent: present" (LLM) mismatches.
1625
+ const STATE_COERCE = {
1626
+ ctaPresent: { 'true': 'present', 'yes': 'present', '1': 'present', 'false': 'absent', 'no': 'absent', '0': 'absent', 'missing': 'absent' },
1627
+ imagesLoadStatus: { 'true': 'allLoaded', 'loaded': 'allLoaded', 'all': 'allLoaded', 'false': 'none', 'failed': 'none', 'broken': 'none', 'error': 'none' },
1628
+ contentState: { 'true': 'published', 'complete': 'published', 'full': 'published', 'available': 'published', 'false': 'missing', 'unavailable': 'missing', 'down': 'missing', 'error': 'missing' },
1629
+ sectionsReadable: { 'true': 'allReadable', 'all': 'allReadable', 'false': 'none', 'unreadable': 'none', 'broken': 'none' },
1630
+ };
1631
+ function canonicalizeStateValues(dataSets) {
1632
+ return dataSets.map((ds) => {
1633
+ const values = { ...ds.values };
1634
+ for (const [key, coerceMap] of Object.entries(STATE_COERCE)) {
1635
+ if (key in values) {
1636
+ const raw = String(values[key]).toLowerCase().trim();
1637
+ if (coerceMap[raw])
1638
+ values[key] = coerceMap[raw];
1639
+ }
1640
+ }
1641
+ return { ...ds, values };
1642
+ });
1643
+ }
1644
+ // ─── STATE MODE ────────────────────────────────────────────────────────────────
1645
+ /**
1646
+ * Extracts concrete values from test steps + FZE inline data without an LLM call.
1647
+ * Surfaces URLs, dates, quoted labels, and recorded selections so the STATE mode
1648
+ * LLM can use actual values instead of abstract keys like `filtersApplied: true`.
1649
+ */
1650
+ function extractConcreteContext(steps, inlineData) {
1651
+ const lines = [];
1652
+ const INLINE_SKIP = new Set(['customScripts', 'contextSwitches', 'recordedUrls', 'recordedSelections', 'recordedUser', 'recordedPassword']);
1653
+ if (inlineData) {
1654
+ const recordedUrls = inlineData.recordedUrls;
1655
+ if (Array.isArray(recordedUrls) && recordedUrls.length > 0) {
1656
+ lines.push(` startUrl: "${recordedUrls[0]}"`);
1657
+ if (recordedUrls.length > 1)
1658
+ lines.push(` additionalUrls: [${recordedUrls.slice(1, 4).map(u => `"${u}"`).join(', ')}]`);
1659
+ }
1660
+ const recordedUser = inlineData.recordedUser;
1661
+ if (recordedUser)
1662
+ lines.push(` loginAs: "${recordedUser}"`);
1663
+ const recordedSelections = inlineData.recordedSelections;
1664
+ if (Array.isArray(recordedSelections) && recordedSelections.length > 0) {
1665
+ lines.push(` selectedValues: [${recordedSelections.slice(0, 8).map(s => `"${s}"`).join(', ')}]`);
1666
+ }
1667
+ for (const [k, v] of Object.entries(inlineData)) {
1668
+ if (!INLINE_SKIP.has(k) && !Array.isArray(v) && v !== null && typeof v !== 'object' && String(v).trim().length > 0 && String(v).trim() !== '[object Object]' && String(v).length < 100) {
1669
+ lines.push(` ${k}: "${v}"`);
1670
+ }
1671
+ }
1672
+ }
1673
+ const stepText = steps.join('\n');
1674
+ // URLs from step text (dedupe, cap at 3)
1675
+ const urlMatches = [...new Set([...stepText.matchAll(/https?:\/\/[^\s"'<>)]+/g)].map(m => m[0].replace(/[.,;)]+$/, '')))].slice(0, 3);
1676
+ if (urlMatches.length > 0 && !lines.some(l => l.startsWith(' startUrl'))) {
1677
+ lines.push(` startUrl: "${urlMatches[0]}"`);
1678
+ if (urlMatches.length > 1)
1679
+ lines.push(` additionalUrls: [${urlMatches.slice(1).map(u => `"${u}"`).join(', ')}]`);
1680
+ }
1681
+ // Date ranges and standalone dates
1682
+ const rangeMatch = stepText.match(/(\d{1,2}\/\d{1,2}\/\d{4})\s*[-–]\s*(\d{1,2}\/\d{1,2}\/\d{4})/);
1683
+ if (rangeMatch)
1684
+ lines.push(` dateRange: "${rangeMatch[0]}"`);
1685
+ const dateMatches = [...new Set([
1686
+ ...[...stepText.matchAll(/\d{1,2}\/\d{1,2}\/\d{4}/g)].map(m => m[0]),
1687
+ ...[...stepText.matchAll(/\d{4}-\d{2}-\d{2}/g)].map(m => m[0]),
1688
+ ])].slice(0, 6);
1689
+ if (dateMatches.length > 0)
1690
+ lines.push(` datesInTest: [${dateMatches.map(d => `"${d}"`).join(', ')}]`);
1691
+ // Quoted label strings from assertions/steps — double-quoted AND single-quoted (skip URLs and scripts)
1692
+ const quotedVals = [...new Set([
1693
+ ...[...stepText.matchAll(/"([^"]{2,80})"/g)].map(m => m[1]),
1694
+ ...[...stepText.matchAll(/'([^']{2,80})'/g)].map(m => m[1]),
1695
+ ].filter(s => !/^https?:/.test(s) && !/^script:/.test(s) && !/^locator:/.test(s)))].slice(0, DATA_LIMIT);
1696
+ if (quotedVals.length > 0)
1697
+ lines.push(` namedValues: [${quotedVals.map(v => `"${v}"`).join(', ')}]`);
1698
+ // Verification targets — extract what each step ASSERTS must be present or correct.
1699
+ // "Verify that the primary heading 'X' is displayed" → expectedHeading: "X"
1700
+ // "Check that X is displayed" / "Ensure X is present" / "Confirm X" → verificationTarget
1701
+ const verifyPatterns = [
1702
+ [/(?:verify|check|confirm|validate|assert)\s+that\s+the\s+([\w\s]{3,50}?)\s+'([^']{2,80})'\s+is\s+(?:displayed|visible|present|shown)/gi, 'expectedContent'],
1703
+ [/(?:verify|check|confirm|validate)\s+that\s+the\s+([\w\s-]{3,50}?)\s+(?:is\s+)?(?:displayed|visible|present|shown)/gi, 'verifiedElement'],
1704
+ [/(?:ensure|check)\s+([\w\s-]{3,50}?)\s+(?:load|display|appear|contain|show)\s/gi, 'checkedElement'],
1705
+ ];
1706
+ const verifyHints = [];
1707
+ for (const [pat, hint] of verifyPatterns) {
1708
+ for (const m of stepText.matchAll(pat)) {
1709
+ const val = m[2] ?? m[1];
1710
+ if (val && val.length >= 3 && val.length <= 80) {
1711
+ verifyHints.push(` ${hint}: "${val.trim()}"`);
1712
+ if (verifyHints.length >= 4)
1713
+ break;
1714
+ }
1715
+ }
1716
+ if (verifyHints.length >= 4)
1717
+ break;
1718
+ }
1719
+ if (verifyHints.length > 0) {
1720
+ lines.push(...[...new Set(verifyHints)].slice(0, 4));
1721
+ }
1722
+ // CTA redirect destination — passive redirect only (avoid matching "Navigate to URL" action steps)
1723
+ // Patterns require explicit passive form: "redirected to X", "taken to X", "lands on X"
1724
+ // NOT: "Navigate to X" (that's an action step, not a destination description)
1725
+ const redirectPatterns = [
1726
+ /→\s*(?:user\s+is\s+)?(?:redirected|taken)\s+to\s+(?:the\s+)?([^.;\n]{3,60}?)(?:\s*[.;]|\s*$)/im,
1727
+ /(?:user\s+(?:is|gets?|was))\s+(?:redirected|taken)\s+to\s+(?:the\s+)?([^.;\n]{3,60}?)(?:\s*[.;]|\s*$)/im,
1728
+ /(?:lands?\s+on|arrives?\s+(?:at|on))\s+(?:the\s+)?([^.;\n]{3,60}?)(?:\s*[.;]|\s*$)/im,
1729
+ ];
1730
+ for (const pat of redirectPatterns) {
1731
+ const m = stepText.match(pat);
1732
+ if (m?.[1]) {
1733
+ const dest = m[1].trim();
1734
+ // Reject if captured value looks like a URL (those belong in startUrl, not ctaDestination)
1735
+ if (dest.length >= 3 && dest.length <= 60 && !/^https?:\/\//.test(dest)) {
1736
+ lines.push(` ctaDestination: "${dest}"`);
1737
+ break;
1738
+ }
1739
+ }
1740
+ }
1741
+ // Mobile / responsive check detection — signals LLM to note responsive outcome
1742
+ const hasMobileStep = /(?:test|verify|check|ensure|validate)\s+(?:the\s+)?(?:mobile|responsive(?:ness)?|(?:mobile\s+)?layout)/i.test(stepText);
1743
+ if (hasMobileStep)
1744
+ lines.push(` hasResponsiveCheck: "true"`);
1745
+ if (lines.length === 0)
1746
+ return '';
1747
+ return [
1748
+ 'CONCRETE VALUES FROM TEST STEPS (use these in dataset values — prefer real values over abstract keys):',
1749
+ ...lines,
1750
+ ' → Include startUrl in ALL datasets (VALID, INVALID, EDGE) — the URL under test is constant; only the page STATE varies.',
1751
+ ' → Use dateRange / datesInTest for date-parameterized datasets.',
1752
+ ' → Use namedValues / selectedValues for filter/selection-parameterized datasets.',
1753
+ ' → Use expectedContent / verifiedElement values as dataset field values (e.g. expectedHeading: "Process Line Automation").',
1754
+ ' → If ctaDestination is present, include it as a field in VALID/EDGE datasets (absent in INVALID — page is down).',
1755
+ ' → If hasResponsiveCheck is "true", mention mobile layout check result in expectedOutcome (pass/degraded/not reached).',
1756
+ ].join('\n');
1757
+ }
1758
+ function buildStatePrompt(opts) {
1759
+ const { testCase, domainCtx, groundedOn, inlineData, kind, relatedCases, maxDatasets, dataNote, structuredSteps, screenMarkdown } = opts;
1760
+ const concreteBlock = extractConcreteContext((0, step_utils_js_1.normalizeSteps)(testCase.steps), inlineData);
1761
+ const exclusionsBlock = relatedCaseExclusionBlock(testCase, relatedCases, groundedOn);
1762
+ const system = [
1763
+ 'You generate test DATA for an INTERACTION-BASED test case (button clicks, toggles, links — no form inputs).',
1764
+ '',
1765
+ 'Do NOT generate field:value input data. Generate PRECONDITION STATE datasets.',
1766
+ 'A precondition dataset describes conditions that MUST be true BEFORE the action.',
1767
+ '',
1768
+ 'OUTPUT FORMAT:',
1769
+ '{ "dataSets": [ { "label": string, "category": "VALID"|"INVALID"|"EDGE", "values": { conditionName: value }, "expectedOutcome": string } ] }',
1770
+ '',
1771
+ 'HARD RULES:',
1772
+ ' 1. Keys in "values" describe preconditions derived from the test steps — NOT invented from thin air. Never invent fields like deviceType, networkSpeed, cacheState, browserType, viewport — those are Playwright config, not precondition data.',
1773
+ ' GOOD keys: pageStatus (200/503/404), contentState (published/draft/missing), userRole (admin/viewer), sessionState (active/expired)',
1774
+ ' BAD keys: deviceType, networkSpeed, cacheState, maintenanceMode (unless the test steps explicitly mention these)',
1775
+ ' 2. Prefer CONCRETE values over abstract booleans — use actual URLs, dates, filter names, usernames extracted from the CONCRETE VALUES block. Never use "app.example.com", "example.com", "validUser", or any invented placeholder — use only values that appear in the test steps or CONCRETE VALUES block.',
1776
+ ' BAD: { filtersApplied: true } GOOD: { dateRange: "02/01/2025 - 02/13/2025", department: "All" }',
1777
+ ' BAD: { hasUrl: true } GOOD: { startUrl: "https://stagingnext.acme.com/login" } ← use the ACTUAL URL from test steps',
1778
+ ' 3. Every expectedOutcome must be DETERMINISTIC. No hedging words.',
1779
+ ' 4. Do NOT generate SECURITY datasets — there is no user input surface to inject into.',
1780
+ ' 5. category VALID = preconditions that allow the action to succeed.',
1781
+ ' category INVALID = preconditions that block the action (wrong role, wrong state, etc.).',
1782
+ ' category EDGE = boundary preconditions (first use, last allowed, concurrent session).',
1783
+ ' 6. Output valid JSON only. No preamble.',
1784
+ '',
1785
+ `DOMAIN CONTEXT: ${domainCtx}`,
1786
+ ...(dataNote ? ['', `TEST DESIGNER GUIDANCE (follow precisely — overrides general heuristics):`, dataNote] : []),
1787
+ ].join('\n');
1788
+ const user = [
1789
+ `TEST CASE: ${testCase.title}`,
1790
+ `TEST KIND: ${kind}`,
1791
+ '',
1792
+ 'TEST STEPS:',
1793
+ (0, step_utils_js_1.normalizeSteps)(testCase.steps).map((s, i) => `${i + 1}. ${s}`).join('\n'),
1794
+ '',
1795
+ 'EXPECTED RESULT:',
1796
+ String(testCase.expectedResult ?? ''),
1797
+ '',
1798
+ ...(exclusionsBlock ? [exclusionsBlock, ''] : []),
1799
+ ...(concreteBlock ? [concreteBlock, ''] : []),
1800
+ ...(structuredSteps && structuredSteps.length > 0 ? [
1801
+ '',
1802
+ 'RECORDED ACTIONS (machine-readable step breakdown — use "data" values as the authoritative field:value pairs):',
1803
+ structuredSteps.filter((s) => s.data).map((s) => ` ${s.target ?? s.action}: "${s.data}"`).join('\n'),
1804
+ ] : []),
1805
+ ...(screenMarkdown ? ['', 'PAGE STRUCTURE (use section/landmark names to anchor precondition keys to real page context):', screenMarkdown.slice(0, 4000), ''] : []),
1806
+ 'INTERACTED ELEMENTS (derive precondition context from these):',
1807
+ groundedOn.map((g) => ` - ${g}`).join('\n'),
1808
+ '',
1809
+ `DATASET SCOPE: Be COMPREHENSIVE — generate every distinct precondition state a senior QA engineer would test. Minimum ${DATA_MIN}; no artificial upper limit.\nMANDATORY COVERAGE: You MUST output at least ONE dataset for EACH category: VALID, INVALID, and EDGE. Do NOT output two VALID datasets without also including both INVALID and EDGE — incomplete category coverage is a generation failure.\nInclude: different application/content states, user roles, permission levels, entity states (active/inactive/draft/published). Each dataset must have genuinely different precondition values.\nCRITICAL — DO NOT include network speed, device type, browser type, cache warmth, or viewport size as dataset values — those are Playwright/browser LAUNCH CONFIG, not test data. Only include conditions that the application itself can be in (e.g. page returns 200 vs 503, content present vs missing, user role, session state).`,
1810
+ '',
1811
+ stateKindInstructions(kind),
1812
+ ].join('\n');
1813
+ return { system, user };
1814
+ }
1815
+ function stateKindInstructions(kind) {
1816
+ switch (kind) {
1817
+ case 'STATE_TRANSITION':
1818
+ return 'FOCUS: Every valid state the entity can be in before the action. Include invalid transitions from terminal states.';
1819
+ case 'NEGATIVE':
1820
+ return 'FOCUS: Preconditions that BLOCK the action — wrong role, insufficient permissions, wrong entity state, locked/throttled.';
1821
+ case 'ACCESSIBILITY':
1822
+ return 'FOCUS: Assistive-technology state — keyboard focus position, screen reader mode, high-contrast, zoom level.';
1823
+ case 'SECURITY':
1824
+ return 'FOCUS: Auth/authorization preconditions — unauthenticated, wrong tenant, expired session, insufficient role.';
1825
+ case 'SMOKE':
1826
+ case 'SANITY':
1827
+ case 'REGRESSION':
1828
+ return `FOCUS: Page/feature availability states. Generate EXACTLY one dataset per category — all three are REQUIRED:\n (1) VALID: pageStatus="200", contentState="published", ctaPresent="present", imagesLoadStatus="allLoaded" — page loads fully, all steps pass.\n (2) INVALID: pageStatus="503" or "404", contentState="missing", ctaPresent="absent", imagesLoadStatus="none" — page unavailable, test fails at step 1.\n (3) EDGE: pageStatus="200", contentState="partial", ctaPresent="absent", imagesLoadStatus="partial" — page accessible (200) but degraded: some images broken, CTA missing.\nADDITIONAL KEYS:\n - startUrl (from CONCRETE VALUES block) in EVERY dataset — URL is constant, only state varies.\n - sectionsReadable="allReadable" (VALID), "partial" (EDGE), "none" (INVALID) — only if steps verify sections.\n - expectedHeading="<exact text>" — VALID/EDGE only (page is up, heading is rendered); OMIT from INVALID (page returns 503/404, heading is never rendered).\n - ctaDestination="<destination name>" — VALID/EDGE only if CONCRETE VALUES has ctaDestination; OMIT from INVALID. Value must be the INTERFACE/PAGE NAME (e.g. "contact or enquiry interface"), NOT a URL.\n - In expectedOutcome: if CONCRETE VALUES has hasResponsiveCheck="true", VALID outcome must include "mobile responsive layout passes"; EDGE outcome must include "mobile layout may be affected"; INVALID outcome must include "mobile check not reached — page unavailable".\nKEY NAMES: pageStatus, contentState, ctaPresent, imagesLoadStatus, startUrl — NOT deviceType/networkSpeed/browserType/viewport.\nVALUE FORMAT: pageStatus="200"|"503"|"404"; contentState="published"|"partial"|"missing"; ctaPresent="present"|"absent"; imagesLoadStatus="allLoaded"|"partial"|"none"; sectionsReadable="allReadable"|"partial"|"none".\nDo NOT invent values — use only values from CONCRETE VALUES block or the canonical formats listed above.`;
1829
+ default:
1830
+ return 'Precondition datasets: normal app state (VALID), blocked/error state (INVALID), boundary edge state — first use, recently-deleted entity, concurrent modification. Do NOT use network speed or device type.';
1831
+ }
1832
+ }
1833
+ // ─── Fallbacks (returned ALONE, never concatenated with generated data) ─────────
1834
+ function fallbackDataSets(kind, mode, fields) {
1835
+ if (mode === 'state') {
1836
+ // Auth precondition states are only relevant for security/negative test kinds
1837
+ if (kind === 'SECURITY' || kind === 'NEGATIVE') {
1838
+ return [
1839
+ { label: 'Authenticated admin — normal state', category: 'VALID', values: { userRole: 'admin', authStatus: 'authenticated', initialState: 'active' }, expectedOutcome: 'Action completes successfully.' },
1840
+ { label: 'Unauthenticated — no session', category: 'INVALID', values: { userRole: 'none', authStatus: 'unauthenticated', initialState: 'none' }, expectedOutcome: 'Redirect to /login.' },
1841
+ { label: 'Insufficient permissions', category: 'INVALID', values: { userRole: 'viewer', authStatus: 'authenticated', initialState: 'active' }, expectedOutcome: 'Action blocked; permission error displayed.' },
1842
+ { label: 'Expired session', category: 'EDGE', values: { userRole: 'admin', authStatus: 'session-expired', initialState: 'active' }, expectedOutcome: 'Session refresh prompt shown.' },
1843
+ ];
1844
+ }
1845
+ // Page/feature availability states for smoke, positive, accessibility, edge, etc.
1846
+ // Values use the same format as the AI prompt instructs: present/absent, published/missing/partial
1847
+ return [
1848
+ { label: 'Page accessible — all content present', category: 'VALID', values: { pageStatus: '200', contentState: 'published', ctaPresent: 'present' }, expectedOutcome: 'Page loads with all expected sections, images, and CTA functional.' },
1849
+ { label: 'Page unavailable — server error', category: 'INVALID', values: { pageStatus: '503', contentState: 'missing', ctaPresent: 'absent' }, expectedOutcome: 'Error page or maintenance notice displayed; no test steps complete.' },
1850
+ { label: 'Page accessible — CTA missing', category: 'EDGE', values: { pageStatus: '200', contentState: 'partial', ctaPresent: 'absent' }, expectedOutcome: 'Steps 1–7 pass but step 8 (click CTA) fails — CTA element not found.' },
1851
+ ];
1852
+ }
1853
+ if (mode === 'volume') {
1854
+ return [
1855
+ { label: 'Baseline Volume', category: 'VOLUME', values: { users: '10', rampUp: '1m', duration: '5m' }, expectedOutcome: 'System maintains <500ms p95 latency.' },
1856
+ { label: 'Peak Load', category: 'VOLUME', values: { users: '100', rampUp: '2m', duration: '10m' }, expectedOutcome: 'System maintains <2s p95 latency; 0% errors.' },
1857
+ ];
1858
+ }
1859
+ // Form mode fallback — use real field keys if available, else a safe placeholder
1860
+ const firstField = fields.find((f) => f.dataBearing);
1861
+ const key = firstField?.fieldKey ?? 'field';
1862
+ const exampleValid = firstField?.exampleValue ?? 'valid-value';
1863
+ if (kind === 'NEGATIVE') {
1864
+ return [
1865
+ { label: 'Empty required field', category: 'INVALID', values: { [key]: '' }, expectedOutcome: `Validation error: ${key} is required.` },
1866
+ { label: 'Exceeds max length', category: 'INVALID', values: { [key]: 'a'.repeat((firstField?.maxLength ?? 255) + 1) }, expectedOutcome: `Validation error: ${key} exceeds maximum length.` },
1867
+ ];
1868
+ }
1869
+ if (kind === 'SECURITY') {
1870
+ return [
1871
+ { label: 'SQL injection probe', category: 'SECURITY', values: { [key]: "' OR 1=1 --" }, expectedOutcome: 'Input sanitized; no SQL error detail in response.' },
1872
+ { label: 'XSS probe', category: 'SECURITY', values: { [key]: '<script>alert(1)</script>' }, expectedOutcome: 'Script tag escaped or rejected; no script executed.' },
1873
+ ];
1874
+ }
1875
+ // Build multi-field VALID + INVALID + EDGE using all data-bearing fields.
1876
+ const allDataFields = fields.filter(f => f.dataBearing && f.exampleValue);
1877
+ if (allDataFields.length > 0) {
1878
+ const validVals = {};
1879
+ const invalidVals = {};
1880
+ const edgeVals = {};
1881
+ for (const f of allDataFields) {
1882
+ const v = String(f.exampleValue ?? '').trim();
1883
+ validVals[f.fieldKey] = v;
1884
+ const isEmail = f.dataType === 'email' || /email|address/.test(f.fieldKey.toLowerCase());
1885
+ const isPassword = f.piiClass === 'high';
1886
+ invalidVals[f.fieldKey] = isEmail ? 'not-an-email' : isPassword ? 'wrongpassword' : '';
1887
+ edgeVals[f.fieldKey] = v.length > 2 ? v.slice(0, 1) : `${v}_e`;
1888
+ }
1889
+ return [
1890
+ { label: 'Valid recorded inputs', category: 'VALID', values: validVals, expectedOutcome: 'All inputs accepted; user proceeds to next step.' },
1891
+ { label: 'Invalid / empty required fields', category: 'INVALID', values: invalidVals, expectedOutcome: 'Validation errors displayed; form submission blocked.' },
1892
+ { label: 'Boundary value inputs', category: 'EDGE', values: edgeVals, expectedOutcome: 'System handles minimal inputs; validation may be triggered.' },
1893
+ ];
1894
+ }
1895
+ return [
1896
+ { label: 'Valid standard input', category: 'VALID', values: { [key]: exampleValid }, expectedOutcome: 'Input accepted; user proceeds to next step.' },
1897
+ { label: 'Invalid input', category: 'INVALID', values: { [key]: '' }, expectedOutcome: 'Validation error displayed; field is required.' },
1898
+ { label: 'Boundary value', category: 'EDGE', values: { [key]: 'a' }, expectedOutcome: 'System handles minimal input without error.' },
1899
+ ];
1900
+ }
1901
+ // ─── VOLUME MODE (PERFORMANCE / LOAD) ───────────────────────────────────────
1902
+ function buildVolumePrompt(opts) {
1903
+ const { testCase, kind } = opts;
1904
+ const system = [
1905
+ 'You generate DATA-VOLUME SPECIFICATIONS for a performance/load test case in a QA platform.',
1906
+ '',
1907
+ 'Do NOT generate field:value input datasets. Generate data-setup profiles (row counts, distribution, concurrency).',
1908
+ '',
1909
+ 'OUTPUT FORMAT:',
1910
+ '{ "dataSets": [ { "label": string, "category": "VOLUME", "values": { param: value }, "expectedOutcome": string } ] }',
1911
+ '',
1912
+ 'HARD RULES:',
1913
+ ' 1. Each dataset = one volume/concurrency profile.',
1914
+ ' 2. Include: rowCount, concurrentUsers, requestsPerSecond, dataSeedStrategy, warmupSeconds, durationSeconds.',
1915
+ ' 3. Generate at least 3 profiles: baseline (normal load), peak (expected max), stress (beyond expected max).',
1916
+ ' 4. expectedOutcome: measurable assertion (e.g. "p95 response time < 500ms; no 5xx errors").',
1917
+ ' 5. Output valid JSON only. No preamble.',
1918
+ ].join('\n');
1919
+ const user = [
1920
+ `TEST CASE: ${testCase.title}`,
1921
+ `TEST KIND: ${kind}`,
1922
+ '',
1923
+ 'TEST STEPS:',
1924
+ (0, step_utils_js_1.normalizeSteps)(testCase.steps).map((s, i) => `${i + 1}. ${s}`).join('\n'),
1925
+ '',
1926
+ 'EXPECTED RESULT:',
1927
+ String(testCase.expectedResult ?? ''),
1928
+ '',
1929
+ 'Generate 3 volume profiles: baseline, peak, stress.',
1930
+ ].join('\n');
1931
+ return { system, user };
1932
+ }
1933
+ // ─── BATCH TEST DATA GENERATION ─────────────────────────────────────────────────
1934
+ // JSON schema for batch mode — top-level object keyed by case_0, case_1, etc.
1935
+ const BATCH_DATA_SCHEMA = {
1936
+ type: 'object',
1937
+ additionalProperties: {
1938
+ type: 'object',
1939
+ properties: {
1940
+ dataSets: {
1941
+ type: 'array',
1942
+ items: {
1943
+ type: 'object',
1944
+ properties: {
1945
+ label: { type: 'string' },
1946
+ category: { type: 'string', enum: ['VALID', 'INVALID', 'EDGE', 'SECURITY'] },
1947
+ values: { type: 'object', additionalProperties: { type: 'string' } },
1948
+ expectedOutcome: { type: 'string' },
1949
+ gherkinExample: { type: 'string' },
1950
+ },
1951
+ required: ['label', 'category', 'values', 'expectedOutcome'],
1952
+ },
1953
+ },
1954
+ },
1955
+ required: ['dataSets'],
1956
+ },
1957
+ };
1958
+ /**
1959
+ * Robustly extracts a JSON object keyed by case_N from LLM batch output.
1960
+ * Mirrors extractJson() but anchors on "case_0" instead of "dataSets".
1961
+ */
1962
+ function extractBatchJson(raw) {
1963
+ let slice = null;
1964
+ // Strategy 1: anchor on "case_0" key — the first batch key we expect
1965
+ const caseIdx = raw.indexOf('"case_0"');
1966
+ if (caseIdx !== -1) {
1967
+ let start = caseIdx;
1968
+ while (start > 0 && raw[start] !== '{')
1969
+ start--;
1970
+ const end = raw.lastIndexOf('}');
1971
+ if (start >= 0 && end > start)
1972
+ slice = raw.slice(start, end + 1);
1973
+ }
1974
+ // Strategy 2: strip markdown fences, find outermost { }
1975
+ if (!slice) {
1976
+ const stripped = raw.replace(/```[a-zA-Z]*\n?/g, '').trim();
1977
+ const j = stripped.indexOf('{');
1978
+ const je = stripped.lastIndexOf('}');
1979
+ if (j !== -1 && je !== -1)
1980
+ slice = stripped.slice(j, je + 1);
1981
+ }
1982
+ if (!slice)
1983
+ throw new Error('No JSON object found in batch LLM response');
1984
+ try {
1985
+ return JSON.parse(slice);
1986
+ }
1987
+ catch {
1988
+ let clean = '';
1989
+ let inStr = false;
1990
+ for (let i = 0; i < slice.length; i++) {
1991
+ const c = slice[i];
1992
+ if (c === '\\' && inStr) {
1993
+ clean += c + slice[++i];
1994
+ continue;
1995
+ }
1996
+ if (c === '"') {
1997
+ inStr = !inStr;
1998
+ clean += c;
1999
+ continue;
2000
+ }
2001
+ if (inStr) {
2002
+ if (c === '\n') {
2003
+ clean += '\\n';
2004
+ continue;
2005
+ }
2006
+ if (c === '\r') {
2007
+ clean += '\\r';
2008
+ continue;
2009
+ }
2010
+ if (c === '\t') {
2011
+ clean += '\\t';
2012
+ continue;
2013
+ }
2014
+ }
2015
+ clean += c;
2016
+ }
2017
+ return JSON.parse(clean);
2018
+ }
2019
+ }
2020
+ /**
2021
+ * Generates test data for up to 5 test cases in a single LLM call.
2022
+ *
2023
+ * Caller is responsible for chunking: pass batches of up to 5 cases.
2024
+ * Returns a Map keyed by the original case id, value is the dataSets array
2025
+ * using the same per-dataset shape as generateTestData().
2026
+ *
2027
+ * Cases that the LLM omits from its response are absent from the returned Map
2028
+ * (caller should fall back to generateTestData() for those).
2029
+ */
2030
+ async function generateTestDataBatch(cases, options = {}) {
2031
+ if (cases.length === 0)
2032
+ return new Map();
2033
+ const llmCfg = options.llmCfg ?? (options.tenantId
2034
+ ? await _cachedLLMConfig(options.tenantId)
2035
+ : undefined);
2036
+ let domainCtx = options.skillFile ?? '{"domain":"unknown"}';
2037
+ if (options.tenantId && options.projectId) {
2038
+ const errFile = await _cachedSkillFile(options.tenantId, options.projectId, 'ERROR_CATALOGUE');
2039
+ if (errFile?.content) {
2040
+ domainCtx += `\n\n## Error Catalogue:\n${errFile.content.slice(0, 600)}`;
2041
+ }
2042
+ }
2043
+ const BATCH_SYSTEM_DEFAULT = [
2044
+ 'You generate test DATA for multiple test cases in a QA platform.',
2045
+ '',
2046
+ 'OUTPUT FORMAT — a single JSON object with one key per test case:',
2047
+ '{',
2048
+ ' "case_0": { "dataSets": [ { "label": string, "category": "VALID"|"INVALID"|"EDGE"|"SECURITY", "values": { fieldKey: value }, "expectedOutcome": string } ] },',
2049
+ ' "case_1": { "dataSets": [ ... ] },',
2050
+ ' ...',
2051
+ '}',
2052
+ '',
2053
+ 'HARD RULES — follow exactly:',
2054
+ ' 1. Return EXACTLY one top-level key per input case (case_0, case_1, …). Never omit a case.',
2055
+ ' 2. Every expectedOutcome must be DETERMINISTIC. No hedging: "possibly", "potentially", "may", "might", "should probably", "could" are forbidden.',
2056
+ ' 3. For every INVALID dataset, state the exact rule violated and the exact error text the UI shows.',
2057
+ ' 4. Each case\'s "values" keys must reflect the field inputs described in that case\'s steps.',
2058
+ ' 5. Generate 2–8 datasets per case. Each must represent a meaningfully different scenario.',
2059
+ ' 6. Output valid JSON only. No preamble, no markdown, no trailing commas.',
2060
+ '',
2061
+ `DOMAIN CONTEXT: ${domainCtx}`,
2062
+ ].join('\n');
2063
+ const system = await (0, core_1.getAgentPrompt)('data-gen-sql', undefined, options.tenantId).catch(() => null)
2064
+ ?? BATCH_SYSTEM_DEFAULT;
2065
+ const caseBlocks = cases.map((tc, i) => {
2066
+ const steps = (0, step_utils_js_1.normalizeSteps)(tc.steps).map((s, si) => ` ${si + 1}. ${s}`).join('\n');
2067
+ const lines = [
2068
+ `CASE case_${i}: ${tc.title}`,
2069
+ `STEPS:`,
2070
+ steps,
2071
+ `EXPECTED RESULT: ${tc.expectedResult}`,
2072
+ ];
2073
+ if (tc.preconditions)
2074
+ lines.push(`PRECONDITIONS: ${tc.preconditions}`);
2075
+ if (tc.testDataInline && Object.keys(tc.testDataInline).length > 0) {
2076
+ lines.push(`RECORDED VALUES: ${JSON.stringify(tc.testDataInline)}`);
2077
+ }
2078
+ return lines.join('\n');
2079
+ });
2080
+ const user = [
2081
+ `Generate test data for the following ${cases.length} test case(s):`,
2082
+ '',
2083
+ caseBlocks.join('\n\n---\n\n'),
2084
+ '',
2085
+ 'Return a JSON object with keys case_0 through case_' + (cases.length - 1) + ', each containing a "dataSets" array.',
2086
+ ].join('\n');
2087
+ const result = new Map();
2088
+ let rawResponse;
2089
+ try {
2090
+ const t0 = Date.now();
2091
+ const { text: raw, usage } = await (0, llm_js_1.askTracked)(system, user, llmCfg, 4096, BATCH_DATA_SCHEMA, { temperature: 0, seed: (0, llm_js_1.deterministicSeed)(`${options.tenantId ?? ''}:${options.projectId ?? ''}:batch:${[...cases.map(c => c.id)].sort().join('|')}`) });
2092
+ rawResponse = raw;
2093
+ const latencyMs = Date.now() - t0;
2094
+ const parsed = extractBatchJson(raw);
2095
+ for (let i = 0; i < cases.length; i++) {
2096
+ const batchKey = `case_${i}`;
2097
+ const caseResult = parsed[batchKey];
2098
+ if (!caseResult || !Array.isArray(caseResult.dataSets)) {
2099
+ console.warn(`[data-agent] batch: missing or invalid result for ${batchKey} (id=${cases[i].id})`);
2100
+ continue;
2101
+ }
2102
+ const _fzeReBatch = /\{\{\s*fze\.(random|local)\.[^}]+\}\}/i;
2103
+ const dataSets = caseResult.dataSets.map((ds) => ({
2104
+ label: ds.label ?? 'Dataset',
2105
+ category: ds.category ?? 'VALID',
2106
+ values: ds.values
2107
+ ? Object.fromEntries(Object.entries(ds.values).filter(([, v]) => !_fzeReBatch.test(String(v ?? ''))))
2108
+ : {},
2109
+ expectedOutcome: ds.expectedOutcome ?? '',
2110
+ ...(ds.gherkinExample ? { gherkinExample: ds.gherkinExample } : {}),
2111
+ }));
2112
+ result.set(cases[i].id, dataSets);
2113
+ }
2114
+ if (options.tenantId && options.projectId) {
2115
+ (0, app_brain_1.recordLLMCall)({
2116
+ tenantId: options.tenantId,
2117
+ projectId: options.projectId,
2118
+ agentType: 'data',
2119
+ label: `test_data_batch_${cases.length}`,
2120
+ entityId: cases.map((c) => c.id).join(','),
2121
+ entityType: 'test_data',
2122
+ usage,
2123
+ latencyMs,
2124
+ }).catch(console.error);
2125
+ }
2126
+ }
2127
+ catch (err) {
2128
+ console.error('[data-agent] batch LLM call failed:', err instanceof Error ? err.message : err);
2129
+ if (rawResponse)
2130
+ console.error('[data-agent] batch raw response (first 600):', rawResponse.slice(0, 600));
2131
+ // Return empty map — caller decides whether to fall back to single-case generateTestData()
2132
+ }
2133
+ return result;
2134
+ }
2135
+ // ─── PAIRWISE BLOCK (injected for ≥3 data-bearing fields) ───────────────────────────
2136
+ function buildPairwiseBlock(fields) {
2137
+ return [
2138
+ 'MULTI-FIELD PAIRWISE RULE (this case has 3+ data-bearing fields):',
2139
+ '1. For each field, use the value-classes from its STRATEGY as parameter values',
2140
+ ' (e.g. email → [valid, missing_at, unicode_local]; enum → [each allowedValue]; integer → [min, mid, max]).',
2141
+ '2. Generate a PAIRWISE-covering VALID+INVALID set: every pair of values across any two fields',
2142
+ ' must appear together in at least one dataset. Do NOT emit the full cross-product.',
2143
+ '3. This pairwise set is IN ADDITION to (never a replacement for):',
2144
+ ' - the full single-field BVA boundary rows for each constrained field, and',
2145
+ ' - one security probe per security-eligible field.',
2146
+ '4. Cap total datasets at 24; if pairwise + BVA + security exceeds the cap,',
2147
+ ' drop the lowest-value redundant VALID rows first, never the boundary or security rows.',
2148
+ ].join('\n');
2149
+ }
2150
+ // ─── RELATION-GROUP CROSS-FIELD INTEGRITY ──────────────────────────────────────
2151
+ function buildRelationGroupBlock(fields) {
2152
+ const byGroup = new Map();
2153
+ for (const f of fields) {
2154
+ if (f.relationGroup) {
2155
+ const existing = byGroup.get(f.relationGroup) ?? [];
2156
+ existing.push(f);
2157
+ byGroup.set(f.relationGroup, existing);
2158
+ }
2159
+ }
2160
+ if (byGroup.size === 0)
2161
+ return '';
2162
+ const lines = ['CROSS-FIELD RELATION RULES (fields sharing a group must be mutually consistent):'];
2163
+ for (const [group, groupFields] of byGroup.entries()) {
2164
+ const keys = groupFields.map(f => f.fieldKey).join(', ');
2165
+ lines.push(` GROUP "${group}": [${keys}]`);
2166
+ lines.push(` In VALID rows: values must be mutually consistent (e.g. state belongs to country; confirm_password equals password; end_date >= start_date).`);
2167
+ lines.push(` In INVALID rows: include at least one dataset that VIOLATES this relationship specifically, naming the exact expected error.`);
2168
+ }
2169
+ return lines.join('\n');
2170
+ }
2171
+ /**
2172
+ * Parse a single CSV row, handling quoted fields with commas.
2173
+ */
2174
+ function parseCSVRow(line) {
2175
+ const result = [];
2176
+ let current = '';
2177
+ let inQuotes = false;
2178
+ for (let i = 0; i < line.length; i++) {
2179
+ const ch = line[i];
2180
+ if (ch === '"') {
2181
+ if (inQuotes && line[i + 1] === '"') {
2182
+ current += '"';
2183
+ i++;
2184
+ }
2185
+ else
2186
+ inQuotes = !inQuotes;
2187
+ }
2188
+ else if (ch === ',' && !inQuotes) {
2189
+ result.push(current.trim());
2190
+ current = '';
2191
+ }
2192
+ else {
2193
+ current += ch;
2194
+ }
2195
+ }
2196
+ result.push(current.trim());
2197
+ return result;
2198
+ }
2199
+ /**
2200
+ * Parse a CSV string (with header row) into VALID/INVALID/EDGE test datasets.
2201
+ * Column names map to dataset field names.
2202
+ * Rows are assigned categories based on a 'category' column (if present) or
2203
+ * positionally: first 40% VALID, next 40% INVALID, last 20% EDGE.
2204
+ *
2205
+ * @param csvContent - Raw CSV string content
2206
+ * @param fieldMap - Optional alias map: { csvColumnName: normalizedFieldName }
2207
+ * @returns Array of dataset objects ready for use in test data generation
2208
+ */
2209
+ function parseCsvToDatasets(csvContent, fieldMap) {
2210
+ const lines = csvContent
2211
+ .split(/\r?\n/)
2212
+ .map((l) => l.trim())
2213
+ .filter(Boolean);
2214
+ if (lines.length < 2)
2215
+ return []; // Need at least header + 1 data row
2216
+ // Parse header
2217
+ const headers = parseCSVRow(lines[0]);
2218
+ const hasCategoryCol = headers.some((h) => h.toLowerCase() === 'category');
2219
+ const hasLabelCol = headers.some((h) => h.toLowerCase() === 'label');
2220
+ const rows = [];
2221
+ for (let i = 1; i < lines.length; i++) {
2222
+ const values = parseCSVRow(lines[i]);
2223
+ if (values.length === 0)
2224
+ continue;
2225
+ const row = {};
2226
+ headers.forEach((h, idx) => {
2227
+ const normalizedKey = fieldMap?.[h] ?? h;
2228
+ row[normalizedKey] = values[idx] ?? '';
2229
+ });
2230
+ rows.push(row);
2231
+ }
2232
+ if (rows.length === 0)
2233
+ return [];
2234
+ // Assign categories
2235
+ const datasets = rows.map((row, idx) => {
2236
+ let category = 'VALID';
2237
+ if (hasCategoryCol) {
2238
+ const cat = (row['category'] ?? '').toUpperCase();
2239
+ if (['VALID', 'INVALID', 'EDGE', 'SECURITY'].includes(cat)) {
2240
+ category = cat;
2241
+ }
2242
+ delete row['category'];
2243
+ }
2244
+ else {
2245
+ // Positional assignment: first 40% VALID, next 40% INVALID, last 20% EDGE
2246
+ const ratio = idx / rows.length;
2247
+ if (ratio < 0.4)
2248
+ category = 'VALID';
2249
+ else if (ratio < 0.8)
2250
+ category = 'INVALID';
2251
+ else
2252
+ category = 'EDGE';
2253
+ }
2254
+ const label = hasLabelCol ? (row['label'] ?? `row-${idx + 1}`) : `row-${idx + 1}`;
2255
+ if (hasLabelCol)
2256
+ delete row['label'];
2257
+ return {
2258
+ label,
2259
+ category,
2260
+ values: row,
2261
+ expectedOutcome: category === 'VALID' ? 'Action succeeds' : 'Validation error or rejection',
2262
+ };
2263
+ });
2264
+ return datasets;
2265
+ }
2266
+ /**
2267
+ * Merge externally-provided datasets (from CSV upload) with LLM-generated datasets.
2268
+ * External datasets take priority for matching categories.
2269
+ * Deduplicates by category label.
2270
+ */
2271
+ function mergeExternalDatasets(generated, external) {
2272
+ if (external.length === 0)
2273
+ return generated;
2274
+ // External datasets replace LLM-generated ones of the same category
2275
+ const externalByCategory = new Map();
2276
+ for (const ds of external) {
2277
+ const existing = externalByCategory.get(ds.category) ?? [];
2278
+ existing.push(ds);
2279
+ externalByCategory.set(ds.category, existing);
2280
+ }
2281
+ // Keep generated datasets only for categories not covered by external
2282
+ const keepGenerated = generated.filter((ds) => !externalByCategory.has(ds.category));
2283
+ return [...external, ...keepGenerated];
2284
+ }