haltija 1.1.21 → 1.2.2

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.
@@ -23,12 +23,14 @@ import { fileURLToPath } from 'url'
23
23
  import { formatTree } from './format-tree.mjs'
24
24
  import { formatEvents } from './format-events.mjs'
25
25
  import { formatTestResult, formatSuiteResult } from './format-test.mjs'
26
+ import { substituteGeneratedVars } from './test-data.mjs'
26
27
 
27
28
  const __dirname = dirname(fileURLToPath(import.meta.url))
28
29
 
29
30
  // Command hints - generated from api-schema.ts during build
30
- import hintsJson from './hints.json' with { type: 'json' }
31
- export const COMMAND_HINTS = hintsJson
31
+ // Use readFileSync instead of JSON import to avoid Node.js ExperimentalWarning
32
+ const hintsPath = join(__dirname, 'hints.json')
33
+ export const COMMAND_HINTS = existsSync(hintsPath) ? JSON.parse(readFileSync(hintsPath, 'utf-8')) : {}
32
34
 
33
35
  // Endpoints that use GET (everything else is POST)
34
36
  export const GET_ENDPOINTS = new Set([
@@ -53,6 +55,9 @@ export const COMPOUND_PATHS = {
53
55
  'tabs-open': '/tabs/open',
54
56
  'tabs-close': '/tabs/close',
55
57
  'tabs-focus': '/tabs/focus',
58
+ 'video-start': '/video/start',
59
+ 'video-stop': '/video/stop',
60
+ 'video-status': '/video/status',
56
61
  'recording-start': '/recording/start',
57
62
  'recording-stop': '/recording/stop',
58
63
  'recording-generate': '/recording/generate',
@@ -66,7 +71,7 @@ export const COMPOUND_PATHS = {
66
71
 
67
72
  // GET compound endpoints
68
73
  export const GET_COMPOUND = new Set([
69
- 'mutations-status', 'events-stats', 'select-status', 'select-result'
74
+ 'mutations-status', 'events-stats', 'select-status', 'select-result', 'video-status'
70
75
  ])
71
76
 
72
77
  // How to map positional args to body fields for each endpoint
@@ -89,40 +94,54 @@ export const ARG_MAPS = {
89
94
  wait: (args) => parseWaitArgs(args),
90
95
  call: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), method: args[1], args: args.slice(2).map(tryParseJSON) }),
91
96
  fetch: (args) => ({ url: args[0], prompt: args.slice(1).join(' ') || undefined }),
92
- screenshot: (args) => parseTargetArgs(args),
97
+ screenshot: (args) => {
98
+ const dataUrl = args.includes('--data-url')
99
+ const filtered = args.filter(a => a !== '--data-url')
100
+ return { ...parseTargetArgs(filtered), file: !dataUrl }
101
+ },
93
102
  snapshot: (args) => ({ context: args.join(' ') || undefined }),
94
103
  select: (args) => ({ action: args[0] }),
95
104
  'select-start': () => ({}),
96
105
  'select-cancel': () => ({}),
97
106
  'select-clear': () => ({}),
98
- refresh: (args) => (args.includes('--hard') ? { hard: true } : {}),
107
+ refresh: (args) => (args.includes('--soft') ? { soft: true } : {}),
99
108
  'tabs-open': (args) => ({ url: args[0] }),
100
109
  'tabs-close': (args) => ({ window: args[0] }),
101
110
  'tabs-focus': (args) => ({ window: args[0] }),
111
+ 'video-start': (args) => {
112
+ const body = {}
113
+ for (let i = 0; i < args.length; i++) {
114
+ if (args[i] === '--maxDuration' || args[i] === '--max-duration') body.maxDuration = num(args[++i])
115
+ }
116
+ return body
117
+ },
118
+ 'video-stop': () => ({}),
102
119
  'events-watch': (args) => ({ preset: args[0] || 'interactive' }),
103
120
  'mutations-watch': (args) => ({ preset: args[0] || 'smart' }),
104
121
  form: (args) => parseTargetArgs(args),
105
122
  // send <agent> <message> or send selection/recording
106
123
  // --no-submit flag prevents auto-submit (paste only)
107
124
  'test-run': (args) => {
108
- if (!args.length) { console.error('Usage: hj test-run <file.json> [--vars JSON] [--timeoutMs N] [--allow-failures N]'); process.exit(1) }
125
+ if (!args.length) { console.error('Usage: hj test-run <file.json> [--vars JSON] [--seed N] [--timeoutMs N] [--allow-failures N]'); process.exit(1) }
109
126
  const { files, options, vars } = parseTestArgs(args)
110
127
  if (!files.length) { console.error('Usage: hj test-run <file.json>'); process.exit(1) }
111
- return { ...readTestFile(files[0], vars), ...options }
128
+ const { seed, ...restOptions } = options
129
+ return { ...readTestFile(files[0], vars, seed), ...restOptions }
112
130
  },
113
131
  'test-validate': (args) => {
114
132
  if (!args.length) { console.error('Usage: hj test-validate <file.json> [--vars JSON]'); process.exit(1) }
115
- const { files, vars } = parseTestArgs(args)
133
+ const { files, vars, options } = parseTestArgs(args)
116
134
  if (!files.length) { console.error('Usage: hj test-validate <file.json>'); process.exit(1) }
117
- return readTestFile(files[0], vars)
135
+ return readTestFile(files[0], vars, options.seed)
118
136
  },
119
137
  'test-suite': (args) => {
120
- if (!args.length) { console.error('Usage: hj test-suite <dir|file...> [--vars JSON] [--timeoutMs N] [--allow-failures N]'); process.exit(1) }
138
+ if (!args.length) { console.error('Usage: hj test-suite <dir|file...> [--vars JSON] [--seed N] [--timeoutMs N] [--allow-failures N]'); process.exit(1) }
121
139
  const { files: rawFiles, options, vars } = parseTestArgs(args)
122
140
  const files = expandTestFiles(rawFiles)
123
141
  if (!files.length) { console.error('Error: No test files found'); process.exit(1) }
124
- const tests = files.map(f => readTestFile(f, vars).test)
125
- return { tests, ...options }
142
+ const { seed, ...restOptions } = options
143
+ const tests = files.map(f => readTestFile(f, vars, seed).test)
144
+ return { tests, ...restOptions }
126
145
  },
127
146
  'send-message': (args) => {
128
147
  const noSubmit = args.includes('--no-submit')
@@ -271,26 +290,48 @@ export function parseModifiers(args) {
271
290
  /**
272
291
  * Substitute template variables in a string.
273
292
  * Replaces ${VAR_NAME} with values from vars object, falling back to env vars.
293
+ * Also handles ${GEN.TYPE} patterns for generated test data.
274
294
  * Unresolved variables are left as-is for debugging.
275
295
  */
276
- export function substituteVars(text, vars = {}) {
277
- return text.replace(/\$\{([^}]+)\}/g, (match, varName) => {
296
+ export function substituteVars(text, vars = {}, seed) {
297
+ // First pass: replace ${GEN.*} patterns with generated test data
298
+ let genInfo = null
299
+ if (/\$\{GEN\./i.test(text)) {
300
+ genInfo = substituteGeneratedVars(text, seed)
301
+ text = genInfo.result
302
+ }
303
+
304
+ // Second pass: replace ${VAR_NAME} with explicit vars / env vars
305
+ const result = text.replace(/\$\{([^}]+)\}/g, (match, varName) => {
278
306
  const trimmed = varName.trim()
279
307
  if (trimmed in vars) return vars[trimmed]
280
308
  if (trimmed in process.env) return process.env[trimmed]
281
309
  return match // Leave unresolved for debugging
282
310
  })
311
+
312
+ return { text: result, genInfo }
283
313
  }
284
314
 
285
315
  /** Read a test JSON file, returning { test: <parsed> }. Applies template variable substitution. */
286
- function readTestFile(filePath, vars = {}) {
316
+ function readTestFile(filePath, vars = {}, seed) {
287
317
  if (!existsSync(filePath)) {
288
318
  console.error(`Error: File not found: ${filePath}`)
289
319
  process.exit(1)
290
320
  }
291
321
  try {
292
322
  const content = readFileSync(filePath, 'utf-8')
293
- const processed = substituteVars(content, vars)
323
+ const { text: processed, genInfo } = substituteVars(content, vars, seed)
324
+
325
+ // Report generated values if any
326
+ if (genInfo && Object.keys(genInfo.generated).length > 0) {
327
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`
328
+ console.error(dim(`[test-data] seed: ${genInfo.seed}`))
329
+ for (const [key, value] of Object.entries(genInfo.generated)) {
330
+ const display = value.length > 60 ? value.slice(0, 57) + '...' : value
331
+ console.error(dim(` ${key} = ${JSON.stringify(display)}`))
332
+ }
333
+ }
334
+
294
335
  const parsed = JSON.parse(processed)
295
336
  return { test: parsed }
296
337
  } catch (err) {
@@ -319,6 +360,9 @@ export function parseTestArgs(args) {
319
360
  } else if (arg === '--step-delay' && args[i + 1]) {
320
361
  options.stepDelay = parseInt(args[i + 1], 10)
321
362
  i += 2
363
+ } else if (arg === '--seed' && args[i + 1]) {
364
+ options.seed = parseInt(args[i + 1], 10)
365
+ i += 2
322
366
  } else if (arg === '--vars' && args[i + 1]) {
323
367
  // Parse JSON object of variables: --vars '{"APP_URL": "http://localhost:5050"}'
324
368
  try {
@@ -462,8 +506,14 @@ async function startServerInBackground(port) {
462
506
  export async function runSubcommand(subcommand, subArgs, port = '8700') {
463
507
  const baseUrl = `http://localhost:${port}`
464
508
  const jsonOutput = subArgs.includes('--json')
465
- // Remove --json from subArgs before processing
466
- const filteredArgs = subArgs.filter(a => a !== '--json')
509
+ // Remove --json and extract --window before processing
510
+ let filteredArgs = subArgs.filter(a => a !== '--json')
511
+ let targetWindowId = undefined
512
+ const windowIdx = filteredArgs.indexOf('--window')
513
+ if (windowIdx !== -1) {
514
+ targetWindowId = filteredArgs[windowIdx + 1]
515
+ filteredArgs = [...filteredArgs.slice(0, windowIdx), ...filteredArgs.slice(windowIdx + 2)]
516
+ }
467
517
 
468
518
  // Check if server is running, auto-start if not
469
519
  if (!(await isServerRunning(port))) {
@@ -517,18 +567,15 @@ export async function runSubcommand(subcommand, subArgs, port = '8700') {
517
567
  }
518
568
  }
519
569
 
520
- // Handle window targeting via --window flag
521
- const windowIdx = filteredArgs.indexOf('--window')
522
- if (windowIdx !== -1 && filteredArgs[windowIdx + 1]) {
523
- const windowId = filteredArgs[windowIdx + 1]
570
+ // Handle window targeting via --window flag (extracted earlier)
571
+ if (targetWindowId) {
524
572
  if (isGet) {
525
- // Append as query param for GET
526
573
  const url = new URL(path, baseUrl)
527
- url.searchParams.set('window', windowId)
574
+ url.searchParams.set('window', targetWindowId)
528
575
  return doRequest(url.toString(), 'GET', undefined, { subcommand, jsonOutput })
529
576
  } else {
530
577
  if (!body) body = {}
531
- body.window = windowId
578
+ body.window = targetWindowId
532
579
  }
533
580
  }
534
581
 
@@ -560,6 +607,18 @@ async function doRequest(url, method, body, context = {}) {
560
607
  console.log(formatTestResult(json))
561
608
  } else if (!jsonOutput && subcommand === 'test-suite' && json.results) {
562
609
  console.log(formatSuiteResult(json))
610
+ } else if (!jsonOutput && subcommand === 'screenshot' && json.data?.path) {
611
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`
612
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`
613
+ console.log(bold(json.data.path))
614
+ const meta = [json.data.width && json.data.height ? `${json.data.width}×${json.data.height}` : null, json.data.format, json.data.source].filter(Boolean).join(', ')
615
+ if (meta) console.log(dim(meta))
616
+ } else if (!jsonOutput && subcommand === 'video-stop' && json.data?.path) {
617
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`
618
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`
619
+ console.log(bold(json.data.path))
620
+ const meta = [json.data.duration ? `${json.data.duration.toFixed(1)}s` : null, json.data.size ? `${(json.data.size / 1024).toFixed(0)}KB` : null, json.data.format].filter(Boolean).join(', ')
621
+ if (meta) console.log(dim(meta))
563
622
  } else {
564
623
  console.log(JSON.stringify(json, null, 2))
565
624
  }
@@ -602,6 +661,7 @@ export const KNOWN_COMMANDS = new Set([
602
661
  'screenshot', 'snapshot', 'highlight', 'unhighlight',
603
662
  'select-start', 'select-result', 'select-cancel', 'select-clear',
604
663
  'windows', 'tabs-open', 'tabs-close', 'tabs-focus',
664
+ 'video-start', 'video-stop', 'video-status',
605
665
  'recording', 'recording-start', 'recording-stop', 'recording-generate', 'recordings',
606
666
  'test-run', 'test-validate', 'test-suite',
607
667
  'send', 'send-message', 'send-selection', 'send-recording',
@@ -676,7 +736,7 @@ Subcommands (replace curl with simple commands):
676
736
 
677
737
  ${bold('Navigate')}
678
738
  navigate <url> Go to URL
679
- refresh [--hard] Reload page
739
+ refresh [--soft] Reload page (hard by default)
680
740
  location Current URL and title
681
741
 
682
742
  ${bold('Observe')}
@@ -693,10 +753,13 @@ Subcommands (replace curl with simple commands):
693
753
  fetch <url> [prompt] Fetch and process URL
694
754
 
695
755
  ${bold('Capture')}
696
- screenshot [@ref|selector] Take screenshot
756
+ screenshot [@ref|selector] Take screenshot (saves to /tmp)
697
757
  snapshot [context] Full page state capture
698
758
  highlight <@ref|selector> Highlight element
699
759
  unhighlight Remove highlights
760
+ video-start [--maxDuration s] Start video recording
761
+ video-stop Stop recording, get file path
762
+ video-status Check recording state
700
763
 
701
764
  ${bold('Selection')}
702
765
  select-start Begin region selection
package/bin/hints.json CHANGED
@@ -1,20 +1,25 @@
1
1
  {
2
2
  "tree": "-d 5 (deeper), --compact, \"#selector\" | see: inspect, query, click",
3
- "query": "\"selector\", --all | see: tree, inspect",
3
+ "query": "@ref or \"selector\", --all | see: tree, inspect",
4
+ "inspect": "@ref or \"selector\", --styles, --rules, --ancestors | see: tree, query",
4
5
  "click": "@ref or \"selector\", :text(Button), --diff | see: tree, wait, type",
5
6
  "type": "@ref, --clear, --humanlike false (fast) | see: click, key",
6
7
  "key": "<key> --ctrl --shift --alt --meta, --repeat 3 | see: type, click",
7
- "drag": "\"selector\" <deltaX> <deltaY>, --duration 500 | see: click, scroll",
8
- "highlight": "\"selector\", --label \"text\", --color #f00, --duration 3000 | see: unhighlight, screenshot",
9
- "scroll": "\"selector\" or <deltaY>, --duration 500 | see: click, wait",
8
+ "drag": "@ref or \"selector\" <deltaX> <deltaY>, --duration 500 | see: click, scroll",
9
+ "highlight": "@ref or \"selector\", --label \"text\", --color #f00, --duration 3000 | see: unhighlight, screenshot",
10
+ "scroll": "@ref or \"selector\" or <deltaY>, --duration 500 | see: click, wait",
10
11
  "wait": "\"selector\", --text \"content\", --timeout 5000 | see: click, navigate",
11
12
  "events": "events-watch first | see: recording, console, mutations-watch",
12
13
  "eval": "\"code\" (returns result) | see: console, snapshot",
14
+ "call": "@ref or \"selector\" <method>, --args [...] | see: eval, inspect",
13
15
  "screenshot": "[selector], --scale 0.5, --maxWidth 800 | see: highlight, snapshot",
14
16
  "windows": "--json | see: tabs-open, tabs-close, tabs-focus, status",
15
17
  "tabs-open": "[url] | see: tabs-focus, tabs-close, windows",
16
18
  "tabs-close": "<window-id> | see: windows, tabs-focus, tabs-open",
17
19
  "tabs-focus": "<window-id> | see: windows, tabs-close, tabs-open",
18
20
  "recording": "start, stop, list, replay <id|index> | see: test-run, events",
21
+ "video-start": "--maxDuration 120 | see: video-stop, video-status, screenshot",
22
+ "video-stop": "| see: video-start, video-status",
23
+ "video-status": "| see: video-start, video-stop",
19
24
  "status": "--json | see: windows, stats, console"
20
25
  }
package/bin/hj.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  * hj status # Server status
11
11
  */
12
12
 
13
- import { runSubcommand, isSubcommand, getSuggestion, listSubcommands } from './cli-subcommand.mjs'
13
+ import { runSubcommand, isSubcommand, getSuggestion, listSubcommands, COMMAND_HINTS } from './cli-subcommand.mjs'
14
14
 
15
15
  const args = process.argv.slice(2)
16
16
 
@@ -42,7 +42,13 @@ const subArgs = args.slice(1).filter(a => a !== '--window' || true) // keep all
42
42
  if (!isSubcommand(subcommand)) {
43
43
  const suggestion = getSuggestion(subcommand)
44
44
  if (suggestion === '--help') {
45
- console.log(listSubcommands())
45
+ // hj help <topic> — filter help output by topic
46
+ const topic = args[1]
47
+ if (topic) {
48
+ filterHelp(topic)
49
+ } else {
50
+ console.log(listSubcommands())
51
+ }
46
52
  process.exit(0)
47
53
  }
48
54
 
@@ -57,3 +63,56 @@ if (!isSubcommand(subcommand)) {
57
63
  } else {
58
64
  runSubcommand(subcommand, subArgs, port)
59
65
  }
66
+
67
+ function filterHelp(topic) {
68
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`
69
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`
70
+ const needle = topic.toLowerCase()
71
+ const helpText = listSubcommands()
72
+ const lines = helpText.split('\n')
73
+
74
+ const matches = []
75
+ let currentCategory = ''
76
+
77
+ for (const line of lines) {
78
+ // Detect category headers (bold ANSI text with no leading spaces beyond the initial 2)
79
+ if (line.match(/^\s{2}\x1b\[1m/)) {
80
+ currentCategory = line
81
+ continue
82
+ }
83
+
84
+ // Match content lines against topic
85
+ const stripped = line.replace(/\x1b\[[0-9;]*m/g, '').toLowerCase()
86
+ if (stripped.trim() && stripped.includes(needle)) {
87
+ matches.push({ category: currentCategory, line })
88
+ }
89
+ }
90
+
91
+ if (matches.length === 0) {
92
+ console.log(`No commands matching '${topic}'.`)
93
+ console.log(`Run ${dim('hj help')} to see all commands.`)
94
+ return
95
+ }
96
+
97
+ console.log(`\nCommands matching '${bold(topic)}':\n`)
98
+ let lastCategory = ''
99
+ for (const m of matches) {
100
+ if (m.category && m.category !== lastCategory) {
101
+ console.log(m.category)
102
+ lastCategory = m.category
103
+ }
104
+ console.log(m.line)
105
+ }
106
+
107
+ // Also show matching hints
108
+ const hintMatches = Object.entries(COMMAND_HINTS).filter(([cmd, hint]) =>
109
+ cmd.toLowerCase().includes(needle) || hint.toLowerCase().includes(needle)
110
+ )
111
+ if (hintMatches.length > 0) {
112
+ console.log(`\n ${bold('Hints')}`)
113
+ for (const [cmd, hint] of hintMatches) {
114
+ console.log(` ${bold(cmd.padEnd(28))} ${dim(hint)}`)
115
+ }
116
+ }
117
+ console.log('')
118
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Test Data Generators (Node.js / CLI version)
3
+ *
4
+ * Lightweight port of src/test-data.ts for use in bin/cli-subcommand.mjs.
5
+ * Produces deterministic, recognizable test data from a seed.
6
+ *
7
+ * Usage:
8
+ * import { createTestDataGenerator, substituteGeneratedVars } from './test-data.mjs'
9
+ * const gen = createTestDataGenerator(42)
10
+ * gen.generate('EMAIL') // "tessia.7f3a@haltija-test.example"
11
+ */
12
+
13
+ // ============================================
14
+ // Seeded PRNG (xorshift32)
15
+ // ============================================
16
+
17
+ function xorshift32(state) {
18
+ let s = state | 0
19
+ s ^= s << 13
20
+ s ^= s >>> 17
21
+ s ^= s << 5
22
+ return [s >>> 0, s >>> 0]
23
+ }
24
+
25
+ class SeededRandom {
26
+ constructor(seed) {
27
+ this.state = (seed === 0 ? 1 : seed) >>> 0
28
+ }
29
+ next() {
30
+ const [value, newState] = xorshift32(this.state)
31
+ this.state = newState
32
+ return value / 0x100000000
33
+ }
34
+ int(min, max) {
35
+ return min + Math.floor(this.next() * (max - min + 1))
36
+ }
37
+ pick(arr) {
38
+ return arr[this.int(0, arr.length - 1)]
39
+ }
40
+ hex(len) {
41
+ let s = ''
42
+ for (let i = 0; i < len; i++) s += this.int(0, 15).toString(16)
43
+ return s
44
+ }
45
+ }
46
+
47
+ // ============================================
48
+ // Data pools
49
+ // ============================================
50
+
51
+ const FIRST_NAMES = [
52
+ 'Tessia', 'Testopher', 'Testina', 'Qadir', 'Qaleen',
53
+ 'Checkov', 'Validia', 'Assertia', 'Debugson', 'Mockwell',
54
+ 'Fixturia', 'Stubson', 'Spectra', 'Suitewell', 'Runley',
55
+ 'Passandra', 'Failsworth', 'Edgeworth', 'Boundara', 'Flaxton',
56
+ ]
57
+
58
+ const WORDS = [
59
+ 'quick', 'brown', 'fox', 'lazy', 'dog', 'test', 'data',
60
+ 'jumps', 'over', 'fence', 'under', 'bridge', 'through',
61
+ 'forest', 'around', 'mountain', 'beside', 'river', 'across',
62
+ 'valley', 'between', 'clouds', 'above', 'ocean', 'below',
63
+ ]
64
+
65
+ const COMPANIES = [
66
+ 'Haltija Test Corp', 'QA Industries', 'Assertion Labs',
67
+ 'Testify Inc', 'Validate Co', 'Fixture Holdings',
68
+ 'Mock & Sons', 'Spec Systems', 'Check Group', 'Edge Corp',
69
+ ]
70
+
71
+ const STREETS = [
72
+ 'Test Avenue', 'QA Boulevard', 'Assertion Lane', 'Validate Street',
73
+ 'Debug Drive', 'Fixture Road', 'Mock Court', 'Spec Way',
74
+ 'Check Circle', 'Edge Parkway', 'Suite Plaza', 'Run Terrace',
75
+ ]
76
+
77
+ const CITIES = [
78
+ 'Testville', 'QA City', 'Assertonia', 'Validateburg',
79
+ 'Debugton', 'Mockford', 'Specburgh', 'Fixtureopolis',
80
+ ]
81
+
82
+ // ============================================
83
+ // Evil / Adversarial strings
84
+ // ============================================
85
+
86
+ const EVIL_XSS = [
87
+ `<script>alert('xss')</script>`,
88
+ `"><img src=x onerror=alert('xss')>`,
89
+ `'><svg/onload=alert('xss')>`,
90
+ `javascript:alert('xss')`,
91
+ `<img src="x" onerror="alert(document.cookie)">`,
92
+ `<div onmouseover="alert('xss')">hover me</div>`,
93
+ `\x3cscript\x3ealert('xss')\x3c/script\x3e`,
94
+ `<iframe src="javascript:alert('xss')"></iframe>`,
95
+ `<body onload=alert('xss')>`,
96
+ `<input onfocus=alert('xss') autofocus>`,
97
+ ]
98
+
99
+ const EVIL_SQL = [
100
+ `'; DROP TABLE users; --`,
101
+ `1 OR 1=1`,
102
+ `' UNION SELECT * FROM users --`,
103
+ `1; UPDATE users SET role='admin' WHERE 1=1; --`,
104
+ `' OR '1'='1`,
105
+ `'; EXEC xp_cmdshell('whoami'); --`,
106
+ `1' AND (SELECT COUNT(*) FROM users) > 0 --`,
107
+ `admin'--`,
108
+ `' OR 1=1 LIMIT 1 --`,
109
+ `'; INSERT INTO log VALUES('pwned'); --`,
110
+ ]
111
+
112
+ const EVIL_UNICODE = [
113
+ '\u200B\u200C\u200D\uFEFF',
114
+ '\u202E\u0052\u0065\u0076\u0065\u0072\u0073\u0065',
115
+ '\u0410\u0412\u0421',
116
+ 'A\u0300\u0301\u0302\u0303\u0304',
117
+ '\uFFFD\uFFFD\uFFFD',
118
+ '\u2028\u2029',
119
+ '\u0000\u0001\u0002',
120
+ '\uD800',
121
+ 'a\u034F\u0061',
122
+ '\u200F\u200E',
123
+ ]
124
+
125
+ const EVIL_EMOJI = [
126
+ '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}',
127
+ '\u{1F44B}\u{1F3FD}',
128
+ '\u{1F1FA}\u{1F1F8}',
129
+ '\u{1F468}\u{200D}\u{1F4BB}',
130
+ '\u{1F3F3}\uFE0F\u{200D}\u{1F308}',
131
+ '\u{1F9D1}\u{200D}\u{1F9D1}\u{200D}\u{1F9D2}',
132
+ '\u{1F600}\u{1F601}\u{1F602}\u{1F603}\u{1F604}',
133
+ '\u0023\uFE0F\u{20E3}',
134
+ '\u{1FAE0}',
135
+ '\u{1F600}\u{1F601}\u{1F602}\u{1F923}\u{1F603}\u{1F604}\u{1F605}\u{1F606}\u{1F607}\u{1F970}',
136
+ ]
137
+
138
+ const EVIL_WHITESPACE = [
139
+ ' \t\n\r\x0B\x0C',
140
+ '\u00A0\u2000\u2001\u2002\u2003\u2004',
141
+ '\u2005\u2006\u2007\u2008\u2009\u200A',
142
+ '\u3000',
143
+ '\r\n\r\n\n\r',
144
+ '\t\t\t\t\t\t\t\t',
145
+ ' \u00A0 \u00A0 ',
146
+ '\u205F\u202F',
147
+ ' \u200B ',
148
+ '\u180E\u2060',
149
+ ]
150
+
151
+ const EVIL_NULL = [
152
+ 'null', 'undefined', 'NaN', 'Infinity', '-Infinity',
153
+ 'true', 'false', '0', '-0', '',
154
+ 'None', 'nil', 'NULL', 'void', '[object Object]',
155
+ ]
156
+
157
+ const EVIL_PATH = [
158
+ '../../etc/passwd',
159
+ 'C:\\windows\\system32\\config\\sam',
160
+ '/dev/null',
161
+ '..\\..\\..\\windows\\system32',
162
+ 'file:///etc/passwd',
163
+ '\\\\server\\share\\file',
164
+ '/proc/self/environ',
165
+ 'CON', 'PRN', 'AUX', 'NUL',
166
+ ]
167
+
168
+ const EVIL_FORMAT = [
169
+ '%s%s%s%s%s%s%s%s%s%s',
170
+ '${7*7}',
171
+ '{{constructor.constructor("return this")()}}',
172
+ '#{7*7}',
173
+ '<%= 7*7 %>',
174
+ '{{7*7}}',
175
+ '${toString}',
176
+ '$(whoami)',
177
+ '`whoami`',
178
+ '{${<%[%\'"}}%\\.',
179
+ ]
180
+
181
+ // ============================================
182
+ // Generator
183
+ // ============================================
184
+
185
+ const ALIASES = {
186
+ 'NAME.FIRST': 'PERSON.FIRST',
187
+ 'NAME.LAST': 'PERSON.LAST',
188
+ 'NAME.FULL': 'PERSON.FULL',
189
+ 'NAME': 'PERSON.FULL',
190
+ 'TEXT.SENTENCE': 'TEXT',
191
+ 'WORD': 'TEXT.SHORT',
192
+ 'INT': 'NUMBER',
193
+ 'ADDRESS.POSTAL': 'ADDRESS.ZIP',
194
+ }
195
+
196
+ export function createTestDataGenerator(seed) {
197
+ const actualSeed = seed ?? (Date.now() ^ (Math.random() * 0x100000000)) >>> 0
198
+ const rng = new SeededRandom(actualSeed)
199
+ const tag = rng.hex(4)
200
+ const cache = new Map()
201
+
202
+ function canonicalize(type) {
203
+ const upper = type.toUpperCase()
204
+ return ALIASES[upper] ?? upper
205
+ }
206
+
207
+ function generate(type) {
208
+ const key = canonicalize(type)
209
+ if (cache.has(key)) return cache.get(key)
210
+ const value = generateFresh(key)
211
+ cache.set(key, value)
212
+ return value
213
+ }
214
+
215
+ function generateFresh(upper) {
216
+ if (upper === 'PERSON.FIRST') return rng.pick(FIRST_NAMES)
217
+ if (upper === 'PERSON.LAST') return `Haltija-${tag}`
218
+ if (upper === 'PERSON.FULL') return `${generate('PERSON.FIRST')} ${generate('PERSON.LAST')}`
219
+ if (upper === 'EMAIL') return `${generate('PERSON.FIRST').toLowerCase()}.${tag}@haltija-test.example`
220
+ if (upper === 'PHONE') return `+1-555-0${rng.int(100, 199)}`
221
+ if (upper === 'USERNAME') return `test_${generate('PERSON.FIRST').toLowerCase()}_${tag}`
222
+ if (upper === 'PASSWORD') return `Test!Pass#${tag}${rng.hex(2)}`
223
+
224
+ if (upper === 'TEXT') {
225
+ const len = rng.int(5, 10)
226
+ const words = Array.from({ length: len }, () => rng.pick(WORDS))
227
+ words[0] = words[0][0].toUpperCase() + words[0].slice(1)
228
+ return words.join(' ') + '.'
229
+ }
230
+ if (upper === 'TEXT.SHORT') return rng.pick(WORDS)
231
+ if (upper === 'TEXT.PARAGRAPH') {
232
+ return Array.from({ length: rng.int(3, 6) }, () => generateFresh('TEXT')).join(' ')
233
+ }
234
+
235
+ if (upper === 'NUMBER') return String(rng.int(1, 9999))
236
+ const rangeMatch = upper.match(/^NUMBER\.RANGE\((\d+),\s*(\d+)\)$/)
237
+ if (rangeMatch) return String(rng.int(parseInt(rangeMatch[1]), parseInt(rangeMatch[2])))
238
+
239
+ if (upper === 'UUID') return `hj-${rng.hex(8)}-${rng.hex(4)}-${rng.hex(4)}-${rng.hex(4)}-${rng.hex(12)}`
240
+
241
+ if (upper === 'DATE') {
242
+ const y = rng.int(2024, 2026), m = rng.int(1, 12), d = rng.int(1, 28)
243
+ return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
244
+ }
245
+ if (upper === 'DATE.FUTURE') return new Date(Date.now() + rng.int(1, 365) * 86400000).toISOString().slice(0, 10)
246
+ if (upper === 'DATE.PAST') return new Date(Date.now() - rng.int(1, 365) * 86400000).toISOString().slice(0, 10)
247
+
248
+ if (upper === 'URL') return `https://haltija-test.example/${tag}`
249
+ if (upper === 'COMPANY') return `${rng.pick(COMPANIES)} ${tag}`
250
+ if (upper === 'ADDRESS.STREET') return `${rng.int(1, 9999)} ${rng.pick(STREETS)}`
251
+ if (upper === 'ADDRESS.CITY') return rng.pick(CITIES)
252
+ if (upper === 'ADDRESS.ZIP') return `555${String(rng.int(0, 99)).padStart(2, '0')}`
253
+ if (upper === 'ADDRESS.FULL') return `${generateFresh('ADDRESS.STREET')}, ${generateFresh('ADDRESS.CITY')} ${generateFresh('ADDRESS.ZIP')}`
254
+
255
+ if (upper === 'EVIL.XSS') return rng.pick(EVIL_XSS)
256
+ if (upper === 'EVIL.SQL') return rng.pick(EVIL_SQL)
257
+ if (upper === 'EVIL.UNICODE') return rng.pick(EVIL_UNICODE)
258
+ if (upper === 'EVIL.EMOJI') return rng.pick(EVIL_EMOJI)
259
+ if (upper === 'EVIL.WHITESPACE') return rng.pick(EVIL_WHITESPACE)
260
+ if (upper === 'EVIL.LONG') return 'A'.repeat(10000)
261
+ if (upper === 'EVIL.EMPTY') return ''
262
+ if (upper === 'EVIL.NULL') return rng.pick(EVIL_NULL)
263
+ if (upper === 'EVIL.PATH') return rng.pick(EVIL_PATH)
264
+ if (upper === 'EVIL.FORMAT') return rng.pick(EVIL_FORMAT)
265
+ if (upper === 'EVIL') {
266
+ const cats = ['XSS', 'SQL', 'UNICODE', 'EMOJI', 'WHITESPACE', 'NULL', 'PATH', 'FORMAT']
267
+ return generateFresh(`EVIL.${rng.pick(cats)}`)
268
+ }
269
+
270
+ return `[unknown:${upper}]`
271
+ }
272
+
273
+ return { generate, seed: actualSeed }
274
+ }
275
+
276
+ /**
277
+ * Process a string, replacing all ${GEN.TYPE} patterns with generated values.
278
+ * Same GEN key produces the same value (memoized). Use .2, .3 etc. for distinct instances.
279
+ */
280
+ export function substituteGeneratedVars(text, seed) {
281
+ const gen = createTestDataGenerator(seed)
282
+ const generated = {}
283
+
284
+ const result = text.replace(/\$\{GEN\.([^}]+)\}/g, (_match, type) => {
285
+ const value = gen.generate(type.trim())
286
+ generated[`GEN.${type.trim()}`] = value
287
+ return value
288
+ })
289
+
290
+ return { result, seed: gen.seed, generated }
291
+ }