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.
- package/apps/desktop/index.html +1 -1
- package/apps/desktop/main.js +264 -64
- package/apps/desktop/package.json +11 -3
- package/apps/desktop/preload.js +17 -0
- package/apps/desktop/renderer/agent-status.js +210 -0
- package/apps/desktop/renderer/settings.js +55 -0
- package/apps/desktop/renderer/state.js +98 -0
- package/apps/desktop/renderer/status.js +38 -0
- package/apps/desktop/renderer/tabs.js +374 -0
- package/apps/desktop/renderer/ui-utils.js +180 -0
- package/apps/desktop/renderer/video-capture.js +154 -0
- package/apps/desktop/renderer/webview-events.js +225 -0
- package/apps/desktop/renderer.js +98 -1604
- package/apps/desktop/resources/component.js +265 -55
- package/apps/desktop/webview-preload.js +19 -1
- package/bin/cli-subcommand.mjs +90 -27
- package/bin/hints.json +9 -4
- package/bin/hj.mjs +61 -2
- package/bin/test-data.mjs +291 -0
- package/bin/tosijs-dev.mjs +95 -20
- package/dist/client.js +5 -1
- package/dist/component.js +265 -55
- package/dist/index.js +444 -76
- package/dist/server.js +444 -76
- package/package.json +2 -1
package/bin/cli-subcommand.mjs
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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) =>
|
|
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('--
|
|
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
|
-
|
|
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
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
466
|
-
|
|
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
|
-
|
|
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',
|
|
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 =
|
|
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 [--
|
|
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
|
-
|
|
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
|
+
}
|