testronaut 1.2.0 β†’ 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/README.md CHANGED
@@ -1,13 +1,14 @@
1
1
  # πŸ§‘β€πŸš€ Testronaut
2
2
 
3
- **Testronaut** is an autonomous testing framework powered by OpenAI function calling and browser automation. It allows you to define mission-based tests in plain English, then runs them through a real browser to validate UI workflows.
3
+ **Testronaut** is an autonomous testing framework powered by **LLMs and Playwright**.
4
+ It lets you define *mission-based tests* in plain English, then runs them through a real browser to validate UI workflows β€” all while generating human-readable reports.
4
5
 
5
6
  ---
6
7
 
7
8
  ## 🌌 Join the Mission Control Community
8
9
 
9
- Got questions, ideas, or want to share your missions?
10
- Join our Discord to connect with other Testronauts, get support, and help shape the framework’s future.
10
+ Got questions, ideas, or missions to share?
11
+ Join the Discord to connect with other Testronauts, get support, and help shape the framework’s future.
11
12
 
12
13
  [![Join Discord](https://img.shields.io/badge/Join%20Us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/pBfdef92ba)
13
14
 
@@ -15,46 +16,52 @@ Join our Discord to connect with other Testronauts, get support, and help shape
15
16
 
16
17
  ## πŸš€ Features
17
18
 
18
- - Run real-world UI flows using missions written as plain strings or functions
19
- - Execute entire suites with `runMissions`
20
- - Uses GPT-4o + Playwright to reason and interact with your app
21
- - Dynamic DOM injection and retry logic
22
- - Built-in throttling for OpenAI rate limits
23
- - Modular design β€” define tools and memory handling as you like
19
+ - Write tests in plain English β€” no brittle selectors
20
+ - Runs real browser sessions via **Playwright**
21
+ - Works with **multiple LLM providers** (OpenAI, Google Gemini, and more coming)
22
+ - Modular tool and DOM-reasoning system
23
+ - Dynamic rate-limit and token-tracking logic
24
+ - Generates JSON + HTML reports automatically
24
25
 
25
26
  ---
26
27
 
27
28
  ## πŸ“– Documentation
28
29
 
29
30
  Looking for deeper guides, API references, and examples?
30
- Head over to the official Testronaut Docs:
31
+ Check the official docs:
31
32
 
32
33
  [![Read the Docs](https://img.shields.io/badge/Read%20the%20Docs-2E8555?style=for-the-badge&logo=readthedocs&logoColor=white)](https://docs.testronaut.app)
33
34
 
34
- The docs cover:
35
- - Getting started step-by-step
36
- - Writing advanced missions
37
- - CLI options and configuration
38
- - Integrating with Mission Control
39
- - Troubleshooting & FAQs
35
+ Includes:
36
+ - Quickstart and setup
37
+ - Writing advanced missions
38
+ - Configuring providers and models
39
+ - CLI options
40
+ - Mission Control integration
41
+ - Troubleshooting and FAQs
40
42
 
41
43
  ---
42
44
 
43
45
  ## πŸ“¦ Installation
44
46
 
45
- ```
47
+ ```bash
46
48
  npm install -g testronaut
47
49
  ```
48
- Then initialize with npx, following the instructions:
49
- ```
50
+
51
+ Then initialize your project:
52
+
53
+ ```bash
50
54
  npx testronaut --init
51
55
  ```
52
- run the welcome mission:
53
- ```
56
+
57
+ Run the sample mission:
58
+ ```bash
54
59
  npx testronaut welcome.mission.js
55
60
  ```
56
- πŸ“ Directory Structure
57
- Your project should include a missions/ folder with mission files like:
61
+
62
+ ---
63
+
64
+ ## πŸ“ Project Structure
58
65
 
59
66
  ```
60
67
  missions/
@@ -62,142 +69,137 @@ missions/
62
69
  β”œβ”€β”€ logout.mission.js
63
70
  └── dashboard.mission.js
64
71
  ```
65
- Each file exports a mission string or function and invokes runMissions.
66
72
 
67
- ### ✍️ Writing a Mission File
68
- Create a file in your missions/ directory, e.g. missions/login.mission.js:
73
+ Each mission exports a string or function and calls `runMissions`.
69
74
 
70
- ```
75
+ ---
76
+
77
+ ## ✍️ Example Mission
78
+
79
+ ```js
71
80
  import { runMissions } from 'testronaut';
72
81
 
73
82
  export const loginMission = `
74
83
  Visit ${process.env.URL}.
75
- Fill the username field with ${process.env.USERNAME}.
84
+ Fill the username field with ${process.env.USERNAME}.
76
85
  Fill the password field with ${process.env.PASSWORD}.
77
- Take a screenshot.
78
- Submit the form.
86
+ Click the Login button.
79
87
  Wait for the dashboard to appear.
80
- Take another screenshot.
81
- Report SUCCESS if the dashboard is loaded, otherwise report FAILURE.
88
+ Take a screenshot.
89
+ Report SUCCESS if the dashboard is loaded, otherwise FAILURE.
82
90
  `;
83
91
 
84
92
  export async function executeMission() {
85
- await runMissions({
86
- mission: loginMission
87
- }, "Login Mission");
93
+ await runMissions({ mission: loginMission }, "Login Mission");
88
94
  }
89
95
  ```
90
96
 
91
- Pass in credentials using an .env file. The .env file should also include your Open AI API key (permissions to the completions endpoint required):
97
+ Create a `.env` file with your credentials **and LLM API key** (depending on your chosen provider):
98
+
99
+ ```bash
100
+ # For OpenAI
101
+ OPENAI_API_KEY=sk-...
102
+
103
+ # Or for Gemini
104
+ GEMINI_API_KEY=AIza...
92
105
 
93
- ```
94
- OPENAI_API_KEY=sk-proj-############
95
106
  URL=https://example.com/login
96
107
  USERNAME=example@example.com
97
108
  PASSWORD=********
98
109
  ```
99
110
 
100
- You can chain multiple phases:
111
+ ---
101
112
 
102
- ```
103
- import { runMissions } from 'testronaut';
104
- import { loginMission } from './login.mission.js';
105
- import { logoutMission } from './logout.mission.js';
106
- import { navigateToContactFormMission } from './navigateToContactForm.mission.js';
107
-
108
- export const fillContactFormMission =
109
- `Input the text "example@example.com" in the email field.
110
- Input the phone number (555)555-5555 into the phone number field.
111
- Click the submit button.
112
- Upon submission success there should be a toast notification indicating the form information was saved successfully.
113
- Take a screenshot.
114
- If found, report SUCCESS. Otherwise, report FAILURE.`
113
+ ## 🧠 LLM Provider Support
115
114
 
116
- export async function executeMission() {
117
- await runMissions({
118
- preMission: [loginMission, navigateToContactFormMission],
119
- mission: fillContactFormMission,
120
- postMission: logoutMission,
121
- }, "Fill Contact Form Mission");
122
- }
123
- ```
115
+ Testronaut is provider-agnostic.
116
+ Choose your preferred LLM at init or via environment variables.
124
117
 
125
- ### πŸƒ Running Missions
126
- Run all missions in the missions/ directory:
118
+ ```bash
119
+ # During init
120
+ npx testronaut --init
127
121
 
122
+ # Or override anytime
123
+ TESTRONAUT_PROVIDER=gemini TESTRONAUT_MODEL=gemini-2.5-pro npx testronaut
128
124
  ```
125
+
126
+ Current supported providers:
127
+
128
+ | Provider | Example Models |
129
+ |-----------|----------------|
130
+ | **OpenAI** | gpt-4o, gpt-4.1, o3, gpt-5, etc. |
131
+ | **Google Gemini** | gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-8b |
132
+
133
+ More providers coming soon (Anthropic, Mistral, etc.).
134
+
135
+ ---
136
+
137
+ ## πŸƒ Running Missions
138
+
139
+ Run all missions:
140
+ ```bash
129
141
  npx testronaut
130
142
  ```
131
- Run a specific file:
132
143
 
133
- ```
144
+ Run a specific mission:
145
+ ```bash
134
146
  npx testronaut login.mission.js
135
147
  ```
136
148
 
137
- 🧰 Available Helpers
138
-
139
- ```
140
- runMissions({ preMission, mission, postMission })
149
+ Chain missions together:
150
+ ```js
151
+ await runMissions({
152
+ preMission: [loginMission],
153
+ mission: fillContactFormMission,
154
+ postMission: logoutMission,
155
+ }, "Contact Form Flow");
141
156
  ```
142
- – Runs test phases
157
+ ---
143
158
 
144
- ```
145
- mission(name, fn) and objective(desc, workflow)
146
- ```
147
- – Optional suite syntax
159
+ ## πŸ“‹ Reports
160
+
161
+ Testronaut generates both JSON and HTML reports automatically under:
148
162
 
149
163
  ```
150
- runSuite(objectives)
164
+ missions/mission_reports/
151
165
  ```
152
- – Executes all registered test objectives
153
-
154
- ### πŸ“‹ Reports
155
- HTML and JSON reports automatically created in the ```missions/mission_reports/``` directory for each testronaut run
156
166
 
157
- ### πŸ§ͺ Under the Hood
158
- Uses Playwright for browser automation
167
+ Each includes:
168
+ - Steps executed
169
+ - Token usage
170
+ - Screenshots
171
+ - Pass/Fail summaries
159
172
 
160
- Interacts with OpenAI’s GPT-4o via Function Calling
173
+ ---
161
174
 
162
- Supports modular tool definitions via CHROME_TOOL_MAP
175
+ ## πŸ§ͺ Under the Hood
163
176
 
164
- πŸ”§ Developing Locally
165
- Clone and install locally:
177
+ - **Playwright** for browser automation
178
+ - **LLMs** for reasoning, DOM parsing, and tool use
179
+ - **Token throttling** + adaptive cooldowns
180
+ - **Extensible architecture** for custom tools and workflows
166
181
 
167
- ```
168
- npm install
169
- npm link
170
- ```
171
- Now you can use testronaut in any local project.
182
+ ---
172
183
 
173
- ### πŸ“„ License
174
- MIT
184
+ ## 🧭 Mission Control
175
185
 
176
- --
186
+ [Mission Control](https://mission.testronaut.app) lets you:
187
+ - View all reports in one dashboard
188
+ - Track mission history and success rates
189
+ - Compare results across environments
190
+ - Access screenshots and step details anytime
177
191
 
178
- ### πŸ“Š Track Your Missions Over Time
179
- Want to see your test results evolve, spot trends, and keep all your reports in one place?
180
- Sign up for **Mission Control** β€” your personal dashboard for Testronaut.
192
+ ---
181
193
 
182
- With Mission Control you can:
183
- - πŸ“ˆ Track your mission reports historically
184
- - πŸ” Drill into past runs with full step-by-step details
185
- - πŸ› οΈ Compare results between different builds or environments
186
- - ☁️ Access reports from anywhere, without digging through local files
194
+ ## πŸ“„ License
187
195
 
188
- Getting started is simple:
189
- 1. [![Sign up for Mission Control](https://img.shields.io/badge/Sign%20Up%20for%20Mission%20Control-0A84FF?style=for-the-badge&logo=rocket&logoColor=white)](https://mission.testronaut.app)
190
- 2. Link your account to Testronaut CLI
191
- 3. Watch your mission history grow!
196
+ MIT
192
197
 
193
198
  ---
194
199
 
195
200
  ## β˜• Support the Mission
196
- πŸ€– Built with ❀️ by [Shane Fast](https://github.com/scfast)
197
201
 
198
- If Testronaut has helped you save time or improve your testing, consider fueling the mission!
199
- Your support helps cover hosting, development time, and the occasional coffee needed to debug at 2am.
202
+ πŸ€– Built with ❀️ by [Shane Fast](https://github.com/scfast)
200
203
 
204
+ If Testronaut saves you time, consider fueling the mission:
201
205
  [![Donate](https://img.shields.io/badge/Donate-Coffee%20Fuel%20for%20Testronaut-ff813f?style=for-the-badge&logo=buy-me-a-coffee&logoColor=white)](https://buymeacoffee.com/testronaut)
202
-
203
- ---
package/core/redaction.js CHANGED
@@ -1,78 +1,206 @@
1
- // core/redaction.js
1
+ /**
2
+ * redaction.js
3
+ * -------------
4
+ * Purpose:
5
+ * Centralized redaction utilities for Testronaut logs and plans.
6
+ * - Single source of truth for sensitive keywords
7
+ * - Masks tool args when targeting sensitive fields (e.g., #access, password inputs)
8
+ * - Masks free-form instructions that reveal credentials
9
+ *
10
+ * Design goals:
11
+ * - No side effects (pure functions)
12
+ * - Easy to extend by editing one keyword list
13
+ * - Unit testable via Vitest
14
+ *
15
+ * Related tests:
16
+ * See `tests/core/redaction.test.js`
17
+ *
18
+ * Used by:
19
+ * - CLI log/plan renderers
20
+ * - Tool call serializers (type/fill)
21
+ *
22
+ * Example usage:
23
+ * const safeArgs = redactArgs('fill', { selector: '#access', text: 'P@ssw0rd' });
24
+ * const safePlan = redactPasswordInText(`Type "P@ssw0rd" into #access`);
25
+ */
26
+
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+ // Sensitive Keywords (Single Source of Truth)
29
+ // Add/remove terms here and every consumer updates automatically.
30
+ // Hyphenated forms are expanded to also match `_` and spaces (api-key/api_key/api key).
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+
33
+ export const SENSITIVE_KEYWORDS = [
34
+ 'pass',
35
+ 'password',
36
+ 'pwd',
37
+ 'passwd',
38
+ 'access',
39
+ 'secret',
40
+ 'token',
41
+ 'api-key',
42
+ 'apikey',
43
+ 'bearer',
44
+ 'auth',
45
+ 'pin',
46
+ 'otp',
47
+ ];
48
+
49
+ /**
50
+ * Builds a single RegExp that treats hyphens as `[-_\s]?` to catch variants.
51
+ * @returns {RegExp}
52
+ */
53
+ function buildSensitiveRegex() {
54
+ // Expand hyphen variants: "api-key" -> "api[-_\s]?key"
55
+ const parts = SENSITIVE_KEYWORDS.map(k => k.replace(/-/g, '[-_\\s]?'));
56
+ return new RegExp(`\\b(${parts.join('|')})\\b`, 'i');
57
+ }
2
58
 
59
+ const SENSITIVE_RE = buildSensitiveRegex();
60
+
61
+ // ─────────────────────────────────────────────────────────────────────────────
62
+ // Core helpers
63
+ // ─────────────────────────────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * Safe clone: prefer structuredClone, fallback to JSON.
67
+ * @template T
68
+ * @param {T} obj
69
+ * @returns {T}
70
+ */
3
71
  function clone(obj) {
4
72
  try { return structuredClone(obj); } catch { return JSON.parse(JSON.stringify(obj ?? {})); }
5
73
  }
6
74
 
75
+ /**
76
+ * Returns a masked preview. If showLength is true, includes length for debugging.
77
+ * @param {unknown} val
78
+ * @param {boolean} [showLength=true]
79
+ * @returns {string}
80
+ */
7
81
  export function maskPreview(val, showLength = true) {
8
82
  const s = String(val ?? '');
9
83
  if (!s) return 'β€’β€’β€’β€’β€’β€’';
10
- // Show length for debugging without leaking the value
11
84
  return showLength ? `β€’β€’β€’β€’β€’β€’ (${s.length})` : 'β€’β€’β€’β€’β€’β€’';
12
85
  }
13
86
 
14
- function hasPasswordHint(str = '') {
15
- return /\b(pass(word)?|pwd|passwd)\b/i.test(String(str || ''));
87
+ /**
88
+ * True if the provided string contains sensitive hints (password, token, access, etc.).
89
+ * @param {string} [str='']
90
+ * @returns {boolean}
91
+ */
92
+ function hasSensitiveHint(str = '') {
93
+ return SENSITIVE_RE.test(String(str || ''));
16
94
  }
17
95
 
18
- function isPasswordCall(fnName, args = {}) {
96
+ /**
97
+ * True if an HTML input type itself is sensitive.
98
+ * @param {string} [t='']
99
+ * @returns {boolean}
100
+ */
101
+ function isSensitiveInputType(t = '') {
102
+ return String(t || '').toLowerCase() === 'password';
103
+ }
104
+
105
+ /**
106
+ * Returns true if a tool call (type/fill) looks like it targets a sensitive field.
107
+ * We check selector/label/placeholder/name/role/testId OR inputType=password.
108
+ *
109
+ * @param {'type'|'fill'|string} fnName
110
+ * @param {object} [args={}]
111
+ * @returns {boolean}
112
+ */
113
+ function isSensitiveCall(fnName, args = {}) {
19
114
  if (!(fnName === 'type' || fnName === 'fill')) return false;
20
- const { selector, label, placeholder, name, inputType, role, testId } = args;
115
+ const { selector, label, placeholder, name, inputType, role, testId } = args || {};
21
116
  return (
22
- hasPasswordHint(selector) ||
23
- hasPasswordHint(label) ||
24
- hasPasswordHint(placeholder) ||
25
- hasPasswordHint(name) ||
26
- String(inputType || '').toLowerCase() === 'password' ||
27
- hasPasswordHint(role) ||
28
- hasPasswordHint(testId)
117
+ hasSensitiveHint(selector) ||
118
+ hasSensitiveHint(label) ||
119
+ hasSensitiveHint(placeholder) ||
120
+ hasSensitiveHint(name) ||
121
+ isSensitiveInputType(inputType) ||
122
+ hasSensitiveHint(role) ||
123
+ hasSensitiveHint(testId)
29
124
  );
30
125
  }
31
126
 
127
+ // ─────────────────────────────────────────────────────────────────────────────
128
+ // Public API
129
+ // ─────────────────────────────────────────────────────────────────────────────
130
+
32
131
  /**
33
- * Redact ONLY password values in tool args:
34
- * - Applies to type/fill on password-looking fields
35
- * - Also masks any arg property explicitly named password/pwd/passwd (shallow)
132
+ * Redact ONLY secret-like values in tool args (shallow).
133
+ * - Masks any property whose *key* name looks sensitive (e.g., apiKey, token)
134
+ * - If the call targets a sensitive field, masks common value props (text/value/input/keys)
135
+ *
136
+ * @param {'type'|'fill'|string} fnName
137
+ * @param {Record<string, unknown>} [args={}]
138
+ * @param {{ showLength?: boolean }} [opts]
139
+ * @returns {Record<string, unknown>}
36
140
  */
37
141
  export function redactArgs(fnName, args = {}, { showLength = true } = {}) {
38
142
  const out = clone(args);
39
143
 
40
- // Mask direct props named password/pwd/passwd if present
144
+ // Mask direct props with sensitive-looking keys
41
145
  for (const k of Object.keys(out || {})) {
42
- if (hasPasswordHint(k)) {
146
+ if (hasSensitiveHint(k)) {
43
147
  out[k] = maskPreview(out[k], showLength);
44
148
  }
45
149
  }
46
150
 
47
- // For typing into a password field, mask the value
48
- if (isPasswordCall(fnName, args)) {
49
- if ('text' in out) out.text = maskPreview(out.text, showLength);
50
- if ('value' in out) out.value = maskPreview(out.value, showLength);
51
- if ('input' in out) out.input = maskPreview(out.input, showLength);
52
- if ('keys' in out) out.keys = maskPreview(out.keys, showLength);
151
+ // Mask value-like props when the call targets a sensitive field
152
+ if (isSensitiveCall(fnName, args)) {
153
+ for (const key of ['text', 'value', 'input', 'keys']) {
154
+ if (key in out) out[key] = maskPreview(out[key], showLength);
155
+ }
53
156
  }
54
157
 
55
158
  return out;
56
159
  }
57
160
 
58
161
  /**
59
- * Redact password literals in freeform mission text.
60
- * Heuristic: look for the word "password" then the next quoted string OR
61
- * a token after "with|as|to|=" within ~80 chars.
162
+ * Redact sensitive literals in freeform mission text.
163
+ *
164
+ * Cases:
165
+ * 1) Sensitive-word ... "VALUE"
166
+ * e.g., `password is "hunter2"`
167
+ *
168
+ * 2) Sensitive-word ... (with|as|to|=) VALUE
169
+ * e.g., `token=abcd1234`, `auth with xyz`
170
+ *
171
+ * 3) "VALUE" ... into/in/on/to TARGET (TARGET contains a sensitive hint)
172
+ * e.g., `Type "Imp0st3r123!" into #access`
173
+ *
174
+ * @param {string} [text='']
175
+ * @param {{ showLength?: boolean }} [opts]
176
+ * @returns {string}
62
177
  */
63
178
  export function redactPasswordInText(text = '', { showLength = true } = {}) {
64
179
  let s = String(text ?? '');
65
180
 
66
- // Case 1: password ... "VALUE"
181
+ // Build a reusable source pattern for SENSITIVE_KEYWORDS with hyphen expansion
182
+ const kwParts = SENSITIVE_KEYWORDS.map(k => k.replace(/-/g, '[-_\\s]?'));
183
+ const kwGroup = `(${kwParts.join('|')})`;
184
+
185
+ // Case 1: keyword ... "VALUE"
67
186
  s = s.replace(
68
- /(\bpassword\b[^"'\\\n]{0,80}["'])([^"']+)(["'])/gi,
69
- (_m, pre, pwd, post) => pre + maskPreview(pwd, showLength) + post
187
+ new RegExp(`(\\b${kwGroup}\\b[^"'\\\\\\n]{0,80}["'])([^"']+)(["'])`, 'gi'),
188
+ (_m, pre, _kw, secret, post) => pre + maskPreview(secret, showLength) + post
70
189
  );
71
190
 
72
- // Case 2: password ... (with|as|to|=) VALUE
191
+ // Case 2: keyword ... (with|as|to|=) VALUE
73
192
  s = s.replace(
74
- /(\bpassword\b[^.\n]{0,80}?\b(with|as|to|=)\s*)([^\s"'`]+)/gi,
75
- (_m, pre, _kw, pwd) => pre + maskPreview(pwd, showLength)
193
+ new RegExp(`(\\b${kwGroup}\\b[^.\\n]{0,80}?\\b(with|as|to|=)\\s*)([^\\s"'\\\`]+)`, 'gi'),
194
+ (_m, pre, _kw1, _kw2, secret) => pre + maskPreview(secret, showLength)
195
+ );
196
+
197
+ // Case 3: "VALUE" ... into/in/on/to TARGET (TARGET looks sensitive)
198
+ s = s.replace(
199
+ /(["'`])([^"'`]+)\1\s+(?:into|in|on|to)\s+([^\s,.;:]+)/gi,
200
+ (_m, q, val, target) => {
201
+ if (hasSensitiveHint(target)) return q + maskPreview(val, showLength) + q + ' into ' + target;
202
+ return _m;
203
+ }
76
204
  );
77
205
 
78
206
  return s;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"
@@ -0,0 +1,118 @@
1
+ /**
2
+ * redaction.test.js
3
+ * -----------------
4
+ * Purpose:
5
+ * Vitest unit tests for redaction utilities:
6
+ * - Centralized keyword usage
7
+ * - Tool arg masking (selector & key-name based)
8
+ * - Free-text masking for plans/instructions
9
+ *
10
+ * Test runner:
11
+ * Vitest (https://vitest.dev/)
12
+ *
13
+ * Location:
14
+ * tests/core/redaction.test.js
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest';
18
+ import {
19
+ SENSITIVE_KEYWORDS,
20
+ maskPreview,
21
+ redactArgs,
22
+ redactPasswordInText,
23
+ } from '../../core/redaction.js';
24
+
25
+ describe('redaction utilities', () => {
26
+ describe('SENSITIVE_KEYWORDS (centralized list)', () => {
27
+ it('contains expected baseline keywords', () => {
28
+ const expected = ['password', 'token', 'access', 'api-key', 'auth'];
29
+ for (const k of expected) {
30
+ expect(SENSITIVE_KEYWORDS).toContain(k);
31
+ }
32
+ });
33
+ });
34
+
35
+ describe('maskPreview', () => {
36
+ it('masks and includes length by default', () => {
37
+ expect(maskPreview('hunter2')).toBe('β€’β€’β€’β€’β€’β€’ (7)');
38
+ });
39
+
40
+ it('masks empty-ish values without length', () => {
41
+ expect(maskPreview('')).toBe('β€’β€’β€’β€’β€’β€’');
42
+ expect(maskPreview(null)).toBe('β€’β€’β€’β€’β€’β€’');
43
+ expect(maskPreview(undefined)).toBe('β€’β€’β€’β€’β€’β€’');
44
+ });
45
+
46
+ it('respects showLength=false', () => {
47
+ expect(maskPreview('abcdef', false)).toBe('β€’β€’β€’β€’β€’β€’');
48
+ });
49
+ });
50
+
51
+ describe('redactArgs', () => {
52
+ it('masks when selector contains #access', () => {
53
+ const args = { selector: '#access', text: 'Imp0st3r123!' };
54
+ const redacted = redactArgs('fill', args);
55
+ expect(redacted.text).toMatch(/^β€’β€’β€’β€’β€’β€’ \(\d+\)$/);
56
+ expect(redacted.selector).toBe('#access'); // selector remains visible
57
+ });
58
+
59
+ it('masks when inputType is password', () => {
60
+ const args = { selector: '#login', inputType: 'password', value: 'superSecret' };
61
+ const redacted = redactArgs('type', args);
62
+ expect(redacted.value).toBe('β€’β€’β€’β€’β€’β€’ (11)');
63
+ });
64
+
65
+ it('masks values by sensitive arg key names (e.g., apiKey, token)', () => {
66
+ const args = { apiKey: 'sk-live-abc', token: 'abcd1234', other: 'notSecret' };
67
+ const redacted = redactArgs('noop', args); // fnName not type/fill still masks by key name
68
+ expect(redacted.apiKey).toBe('β€’β€’β€’β€’β€’β€’ (11)');
69
+ expect(redacted.token).toBe('β€’β€’β€’β€’β€’β€’ (8)');
70
+ expect(redacted.other).toBe('notSecret');
71
+ });
72
+
73
+ it('does not mask non-sensitive calls', () => {
74
+ const args = { selector: '#search', text: 'hello world' };
75
+ const redacted = redactArgs('fill', args);
76
+ expect(redacted.text).toBe('hello world');
77
+ });
78
+ });
79
+
80
+ describe('redactPasswordInText', () => {
81
+ it('Case 1: keyword ... "VALUE"', () => {
82
+ const input = `My password is "hunter2".`;
83
+ const out = redactPasswordInText(input);
84
+ expect(out).toContain(`password is "β€’β€’β€’β€’β€’β€’ (7)"`);
85
+ });
86
+
87
+ it('Case 2: keyword ... (with|as|to|=) VALUE', () => {
88
+ const input = `token=abcd1234 and auth with xyz`;
89
+ const out = redactPasswordInText(input);
90
+ expect(out).toContain(`token=β€’β€’β€’β€’β€’β€’ (8)`);
91
+ expect(out).toContain(`auth with β€’β€’β€’β€’β€’β€’ (3)`);
92
+ });
93
+
94
+ it('Case 3: "VALUE" into sensitive target (#access)', () => {
95
+ const input = `Type "Imp0st3r123!" into #access`;
96
+ const out = redactPasswordInText(input);
97
+ expect(out).toBe(`Type "β€’β€’β€’β€’β€’β€’ (12)" into #access`);
98
+ });
99
+
100
+ it('non-sensitive text is unchanged', () => {
101
+ const input = `Click the button and type "hello" into #search`;
102
+ const out = redactPasswordInText(input);
103
+ expect(out).toBe(input);
104
+ });
105
+
106
+ it('handles api key forms with spaces/underscores/hyphens', () => {
107
+ const cases = [
108
+ `api-key "abcDEF123"`,
109
+ `api key "abcDEF123"`,
110
+ `api_key "abcDEF123"`,
111
+ ];
112
+ for (const text of cases) {
113
+ const out = redactPasswordInText(text);
114
+ expect(out).toContain(`"β€’β€’β€’β€’β€’β€’ (9)"`);
115
+ }
116
+ });
117
+ });
118
+ });