@litocodes/persona-test 1.0.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.
- package/.env +11 -0
- package/README.md +159 -0
- package/brain.js +124 -0
- package/index.js +130 -0
- package/package.json +42 -0
- package/parser.js +103 -0
- package/personas.js +107 -0
package/.env
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Cerebras API Configuration
|
|
2
|
+
# Get your API key from: https://cloud.cerebras.ai/
|
|
3
|
+
|
|
4
|
+
# Your Cerebras API Key (required)
|
|
5
|
+
CEREBRAS_API_KEY=csk-fptmf5m4hxn84c3jx95yw9tce2v6yxkhpjkwjpkxd88cmrjp
|
|
6
|
+
|
|
7
|
+
# Model to use (Cerebras models - very fast!)
|
|
8
|
+
# Options:
|
|
9
|
+
# - llama-3.3-70b (recommended - smart and fast)
|
|
10
|
+
# - llama-3.1-8b (lightweight, fastest)
|
|
11
|
+
CEREBRAS_MODEL=llama-3.3-70b
|
package/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# π Persona - AI-Powered User Testing Tool
|
|
2
|
+
|
|
3
|
+
An automated user-testing MVP that uses AI Agents to "look" at a website (via screenshots) and behave like specific human personas.
|
|
4
|
+
|
|
5
|
+
## ποΈ Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
9
|
+
β PERSONA β
|
|
10
|
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
|
|
11
|
+
β β
|
|
12
|
+
β ββββββββββββ ββββββββββββ ββββββββββββ β
|
|
13
|
+
β β ποΈ EYE βββββΆβ π§ BRAIN βββββΆβ ποΈ HAND β β
|
|
14
|
+
β βPlaywrightβ β OpenRouterβ β Locator β β
|
|
15
|
+
β βScreenshotβ β Vision β β Execute β β
|
|
16
|
+
β ββββββββββββ ββββββββββββ ββββββββββββ β
|
|
17
|
+
β β² β β
|
|
18
|
+
β ββββββββββββββββββββββββββββββββββββ β
|
|
19
|
+
β REPEAT LOOP β
|
|
20
|
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
1. **The Eye**: Playwright takes a JPEG screenshot of the current page
|
|
24
|
+
2. **The Brain**: Screenshot + Persona System Prompt sent to Vision LLM
|
|
25
|
+
3. **The Hand**: LLM returns JSON action with visual element description
|
|
26
|
+
4. **The Locator**: Playwright semantic locators find and interact with elements
|
|
27
|
+
|
|
28
|
+
## π¦ Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Clone or create the project
|
|
32
|
+
cd Persona
|
|
33
|
+
|
|
34
|
+
# Install dependencies
|
|
35
|
+
npm install
|
|
36
|
+
|
|
37
|
+
# Install Playwright browsers
|
|
38
|
+
npx playwright install chromium
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## βοΈ Configuration
|
|
42
|
+
|
|
43
|
+
Create a `.env` file with your OpenRouter API key:
|
|
44
|
+
|
|
45
|
+
```env
|
|
46
|
+
OPENAI_API_KEY=your-openrouter-api-key-here
|
|
47
|
+
OPENROUTER_MODEL=google/gemini-2.0-flash-exp:free
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Get your API key from: https://openrouter.ai/keys
|
|
51
|
+
|
|
52
|
+
## π Usage
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Basic usage
|
|
56
|
+
node index.js --url="https://example.com" --agent="zoomer"
|
|
57
|
+
|
|
58
|
+
# With options
|
|
59
|
+
node index.js --url="https://google.com" --agent="boomer" --headless
|
|
60
|
+
|
|
61
|
+
# Test API connection
|
|
62
|
+
node index.js --test
|
|
63
|
+
|
|
64
|
+
# Show help
|
|
65
|
+
node index.js --help
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### CLI Options
|
|
69
|
+
|
|
70
|
+
| Option | Alias | Description |
|
|
71
|
+
|--------|-------|-------------|
|
|
72
|
+
| `--url` | `-u` | Target website URL (required) |
|
|
73
|
+
| `--agent` | `-a` | Persona to simulate (required) |
|
|
74
|
+
| `--headless` | | Run browser without UI |
|
|
75
|
+
| `--test` | | Test OpenRouter connection |
|
|
76
|
+
| `--help` | `-h` | Show help message |
|
|
77
|
+
|
|
78
|
+
## π₯ Available Personas
|
|
79
|
+
|
|
80
|
+
### π§ Boomer (70yo)
|
|
81
|
+
- Low tech literacy
|
|
82
|
+
- Needs high contrast and large text
|
|
83
|
+
- Quits if frustrated
|
|
84
|
+
- **Goal**: Find the contact phone number
|
|
85
|
+
|
|
86
|
+
### β‘ Zoomer (20yo)
|
|
87
|
+
- Extremely impatient
|
|
88
|
+
- Scrolls fast, ignores instructions
|
|
89
|
+
- Prefers social login
|
|
90
|
+
- **Goal**: Sign up as fast as possible
|
|
91
|
+
|
|
92
|
+
### π Skeptic (40yo)
|
|
93
|
+
- Paranoid about privacy
|
|
94
|
+
- Refuses all cookies
|
|
95
|
+
- Checks Privacy Policy
|
|
96
|
+
- **Goal**: Evaluate site trustworthiness
|
|
97
|
+
|
|
98
|
+
### π» Hacker (30yo)
|
|
99
|
+
- Security tester mindset
|
|
100
|
+
- Probes input fields
|
|
101
|
+
- Tests for SQL injection, XSS
|
|
102
|
+
- **Goal**: Test inputs for vulnerabilities
|
|
103
|
+
|
|
104
|
+
## π How It Works
|
|
105
|
+
|
|
106
|
+
1. Launch Chromium browser and navigate to target URL
|
|
107
|
+
2. Take a compressed JPEG screenshot
|
|
108
|
+
3. Send screenshot + persona prompt to Vision LLM
|
|
109
|
+
4. LLM analyzes the page and returns a JSON action:
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"action": "click",
|
|
113
|
+
"selector": "text=Sign Up",
|
|
114
|
+
"reason": "Found signup button, clicking to proceed"
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
5. Execute the action using Playwright semantic locators
|
|
118
|
+
6. Repeat until goal achieved or max actions reached
|
|
119
|
+
|
|
120
|
+
## π― Action Types
|
|
121
|
+
|
|
122
|
+
| Action | Description | Selector Format |
|
|
123
|
+
|--------|-------------|-----------------|
|
|
124
|
+
| `click` | Click an element | `text=Button Text` |
|
|
125
|
+
| `type` | Type into input | `placeholder=Email` |
|
|
126
|
+
| `scroll` | Scroll the page | `down`, `up`, `to=text` |
|
|
127
|
+
| `done` | Goal achieved | N/A |
|
|
128
|
+
| `quit` | Give up | N/A |
|
|
129
|
+
|
|
130
|
+
## π§ Project Structure
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
Persona/
|
|
134
|
+
βββ index.js # Main CLI entry point
|
|
135
|
+
βββ brain.js # OpenRouter vision client
|
|
136
|
+
βββ personas.js # Persona definitions
|
|
137
|
+
βββ package.json # Dependencies
|
|
138
|
+
βββ .env # API configuration
|
|
139
|
+
βββ README.md # This file
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## π Troubleshooting
|
|
143
|
+
|
|
144
|
+
**"OPENAI_API_KEY not set"**
|
|
145
|
+
- Create a `.env` file with your OpenRouter API key
|
|
146
|
+
|
|
147
|
+
**"Element not found"**
|
|
148
|
+
- The AI's selector may not match exactly
|
|
149
|
+
- The element might not be visible
|
|
150
|
+
- Try running with `--headless=false` to see what's happening
|
|
151
|
+
|
|
152
|
+
**"Connection failed"**
|
|
153
|
+
- Check your API key
|
|
154
|
+
- Run `node index.js --test` to verify connection
|
|
155
|
+
- Check OpenRouter status
|
|
156
|
+
|
|
157
|
+
## π License
|
|
158
|
+
|
|
159
|
+
MIT
|
package/brain.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// brain.js - With Action History & Strict Form Flow
|
|
2
|
+
// Reads last 3 actions to understand form context
|
|
3
|
+
|
|
4
|
+
import OpenAI from 'openai';
|
|
5
|
+
import dotenv from 'dotenv';
|
|
6
|
+
|
|
7
|
+
dotenv.config();
|
|
8
|
+
|
|
9
|
+
if (!process.env.CEREBRAS_API_KEY) {
|
|
10
|
+
console.error('β CEREBRAS_API_KEY not set');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MODEL = process.env.CEREBRAS_MODEL || 'llama-3.3-70b';
|
|
15
|
+
|
|
16
|
+
const cerebras = new OpenAI({
|
|
17
|
+
baseURL: 'https://api.cerebras.ai/v1',
|
|
18
|
+
apiKey: process.env.CEREBRAS_API_KEY
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get agent action with full history context
|
|
23
|
+
* @param {string} domMap - DOM with [BROKEN] tags
|
|
24
|
+
* @param {object} persona - Persona profile
|
|
25
|
+
* @param {Array} actionHistory - Last 3 actions [{action, id, text}]
|
|
26
|
+
*/
|
|
27
|
+
export async function getAgentAction(domMap, persona, actionHistory = []) {
|
|
28
|
+
// Format history as readable string
|
|
29
|
+
const historyStr = actionHistory.length > 0
|
|
30
|
+
? actionHistory.map(h => `${h.action.toUpperCase()} [${h.id}] "${h.text}"`).join(' β ')
|
|
31
|
+
: 'None yet';
|
|
32
|
+
|
|
33
|
+
// Check if we just typed (for form flow logic)
|
|
34
|
+
const lastTyped = actionHistory.filter(h => h.action === 'type').length;
|
|
35
|
+
|
|
36
|
+
const systemPrompt = `You are a User Tester acting as: "${persona.name}".
|
|
37
|
+
|
|
38
|
+
PERSONA: ${persona.behavior}
|
|
39
|
+
|
|
40
|
+
GOAL: "${persona.goal}"
|
|
41
|
+
|
|
42
|
+
--- RECENT HISTORY (Last 3 moves) ---
|
|
43
|
+
${historyStr}
|
|
44
|
+
${lastTyped > 0 ? '\nβ οΈ You have already TYPED. If the form is filled, you should now CLICK a Submit/Login button!' : ''}
|
|
45
|
+
|
|
46
|
+
--- CRITICAL RULES ---
|
|
47
|
+
1. π NEVER click/type on elements marked [BROKEN - DO NOT USE]
|
|
48
|
+
2. π FORM FLOW:
|
|
49
|
+
- If you just TYPED into username/password, your NEXT action should be CLICK on a "Submit", "Login", "Create", or "Sign up" button
|
|
50
|
+
- Do NOT type into the same field twice
|
|
51
|
+
3. π NO LOOPS: If your history shows the same action 2+ times, do something DIFFERENT
|
|
52
|
+
4. β
GOAL CHECK: If PAGE CONTEXT shows your goal (phone number, success message), say "done"
|
|
53
|
+
5. π€ GIVE UP: If everything is [BROKEN] or stuck, say "quit"
|
|
54
|
+
|
|
55
|
+
${domMap}
|
|
56
|
+
|
|
57
|
+
Return ONLY JSON (no markdown):
|
|
58
|
+
{
|
|
59
|
+
"action": "click" | "type" | "scroll" | "done" | "quit",
|
|
60
|
+
"elementId": 12,
|
|
61
|
+
"inputText": "test_user" (REQUIRED if typing),
|
|
62
|
+
"reason": "brief reason",
|
|
63
|
+
"frustration": 0-10
|
|
64
|
+
}`;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
console.log(' π§ Asking Cerebras...');
|
|
68
|
+
|
|
69
|
+
const response = await cerebras.chat.completions.create({
|
|
70
|
+
model: MODEL,
|
|
71
|
+
messages: [{ role: 'user', content: systemPrompt }],
|
|
72
|
+
max_tokens: 200,
|
|
73
|
+
temperature: 0.7
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const content = response.choices[0]?.message?.content || '';
|
|
77
|
+
return parseAIResponse(content);
|
|
78
|
+
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error(' β AI Error:', error.message);
|
|
81
|
+
return { action: 'quit', reason: 'AI failed', frustration: 10 };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseAIResponse(content) {
|
|
86
|
+
let jsonStr = content.trim();
|
|
87
|
+
if (jsonStr.startsWith('```json')) jsonStr = jsonStr.slice(7);
|
|
88
|
+
else if (jsonStr.startsWith('```')) jsonStr = jsonStr.slice(3);
|
|
89
|
+
if (jsonStr.endsWith('```')) jsonStr = jsonStr.slice(0, -3);
|
|
90
|
+
|
|
91
|
+
const match = jsonStr.match(/\{[\s\S]*\}/);
|
|
92
|
+
if (match) jsonStr = match[0];
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const a = JSON.parse(jsonStr);
|
|
96
|
+
return {
|
|
97
|
+
action: a.action || 'quit',
|
|
98
|
+
elementId: parseInt(a.elementId) || null,
|
|
99
|
+
inputText: a.inputText || a.input_text || '',
|
|
100
|
+
reason: a.reason || '',
|
|
101
|
+
frustration: parseInt(a.frustration) || 0
|
|
102
|
+
};
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return { action: 'quit', reason: 'Parse error', frustration: 10 };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function testConnection() {
|
|
109
|
+
console.log('π§ͺ Testing Cerebras...');
|
|
110
|
+
try {
|
|
111
|
+
const r = await cerebras.chat.completions.create({
|
|
112
|
+
model: MODEL,
|
|
113
|
+
messages: [{ role: 'user', content: 'Say "Brain online"' }],
|
|
114
|
+
max_tokens: 10
|
|
115
|
+
});
|
|
116
|
+
console.log(' β
', r.choices[0]?.message?.content);
|
|
117
|
+
return true;
|
|
118
|
+
} catch (e) {
|
|
119
|
+
console.error(' β', e.message);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default { getAgentAction, testConnection };
|
package/index.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// index.js - With Memory State (History + Blacklist)
|
|
4
|
+
|
|
5
|
+
import { chromium } from 'playwright';
|
|
6
|
+
import minimist from 'minimist';
|
|
7
|
+
import dotenv from 'dotenv';
|
|
8
|
+
import { parsePage, findElementById } from './parser.js';
|
|
9
|
+
import { getAgentAction, testConnection } from './brain.js';
|
|
10
|
+
import { getPersona, listPersonas } from './personas.js';
|
|
11
|
+
|
|
12
|
+
dotenv.config();
|
|
13
|
+
|
|
14
|
+
const argv = minimist(process.argv.slice(2), {
|
|
15
|
+
string: ['url', 'agent'],
|
|
16
|
+
boolean: ['headless', 'test', 'help'],
|
|
17
|
+
default: { headless: false },
|
|
18
|
+
alias: { u: 'url', a: 'agent', h: 'help' }
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
if (argv.help) {
|
|
22
|
+
console.log(`
|
|
23
|
+
π PERSONA - AI User Testing (Final Version)
|
|
24
|
+
|
|
25
|
+
Usage: node index.js --url="<site>" --agent="<persona>"
|
|
26
|
+
Personas: ${listPersonas().join(', ')}
|
|
27
|
+
`);
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (argv.test) { await testConnection(); process.exit(0); }
|
|
32
|
+
if (!argv.url || !argv.agent) { console.error('β --url and --agent required'); process.exit(1); }
|
|
33
|
+
|
|
34
|
+
const persona = getPersona(argv.agent);
|
|
35
|
+
if (!persona) { console.error(`β Unknown: ${argv.agent}`); process.exit(1); }
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
console.log(`
|
|
39
|
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
40
|
+
β π PERSONA - AI User Testing (Final) β
|
|
41
|
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
42
|
+
`);
|
|
43
|
+
console.log(`π― ${argv.url}`);
|
|
44
|
+
console.log(`π€ ${persona.name} | Goal: ${persona.goal}\n`);
|
|
45
|
+
|
|
46
|
+
const browser = await chromium.launch({ headless: argv.headless });
|
|
47
|
+
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
|
48
|
+
|
|
49
|
+
await page.goto(argv.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
50
|
+
await page.waitForTimeout(2000);
|
|
51
|
+
console.log('β
Page loaded\n');
|
|
52
|
+
|
|
53
|
+
// π§ MEMORY STATE
|
|
54
|
+
const actionHistory = []; // Last 3 actions: [{action, id, text}]
|
|
55
|
+
const failedIds = []; // Blacklisted IDs
|
|
56
|
+
|
|
57
|
+
for (let step = 1; step <= persona.maxActions; step++) {
|
|
58
|
+
console.log(`ββ Step ${step}/${persona.maxActions} ${'β'.repeat(44)}`);
|
|
59
|
+
|
|
60
|
+
const html = await page.content();
|
|
61
|
+
const { domMap, elements, elementCount } = parsePage(html, failedIds);
|
|
62
|
+
console.log(` π ${elementCount} elements (${failedIds.length} blacklisted)`);
|
|
63
|
+
|
|
64
|
+
if (elementCount === 0) {
|
|
65
|
+
console.log(' β οΈ No elements');
|
|
66
|
+
await page.mouse.wheel(0, 300);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Pass full history to brain
|
|
71
|
+
const decision = await getAgentAction(domMap, persona, actionHistory);
|
|
72
|
+
|
|
73
|
+
console.log(` π€ ${decision.action.toUpperCase()} [${decision.elementId}]`);
|
|
74
|
+
console.log(` π ${decision.reason}`);
|
|
75
|
+
console.log(` π€ Frustration: ${decision.frustration}/10`);
|
|
76
|
+
|
|
77
|
+
if (decision.action === 'done' || decision.action === 'quit') {
|
|
78
|
+
console.log(`\n${'β'.repeat(60)}`);
|
|
79
|
+
console.log(decision.action === 'done' ? ' π GOAL ACHIEVED!' : ' π€ GAVE UP');
|
|
80
|
+
console.log(` ${decision.reason}`);
|
|
81
|
+
console.log(`${'β'.repeat(60)}`);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const target = decision.elementId ? findElementById(elements, decision.elementId) : null;
|
|
86
|
+
|
|
87
|
+
if (!target) {
|
|
88
|
+
console.log(' β Invalid ID');
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const locator = page.locator(target.selector).first();
|
|
94
|
+
|
|
95
|
+
if (decision.action === 'click') {
|
|
96
|
+
await locator.click({ timeout: 4000 });
|
|
97
|
+
console.log(` π±οΈ Clicked: "${target.text}"`);
|
|
98
|
+
} else if (decision.action === 'type') {
|
|
99
|
+
const text = decision.inputText || persona.testStrings?.sql || 'test_user';
|
|
100
|
+
await locator.fill(text, { timeout: 4000 });
|
|
101
|
+
console.log(` β¨οΈ Typed: "${text}"`);
|
|
102
|
+
} else if (decision.action === 'scroll') {
|
|
103
|
+
await page.mouse.wheel(0, 400);
|
|
104
|
+
console.log(' π Scrolled');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// β
Add to history (keep last 3)
|
|
108
|
+
actionHistory.push({ action: decision.action, id: decision.elementId, text: target.text });
|
|
109
|
+
if (actionHistory.length > 3) actionHistory.shift();
|
|
110
|
+
|
|
111
|
+
} catch (e) {
|
|
112
|
+
const msg = e.message.split('\n')[0].substring(0, 50);
|
|
113
|
+
console.log(` β οΈ FAILED: ${msg}`);
|
|
114
|
+
|
|
115
|
+
// π¨ Add to blacklist
|
|
116
|
+
failedIds.push(decision.elementId);
|
|
117
|
+
console.log(` π« ID [${decision.elementId}] blacklisted`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await page.waitForTimeout(2000);
|
|
121
|
+
console.log(`β${'β'.repeat(58)}β`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log('\nπ History:', actionHistory.map(h => `${h.action}[${h.id}]`).join(' β '));
|
|
125
|
+
console.log('π« Blacklist:', failedIds.length ? failedIds.join(', ') : 'none');
|
|
126
|
+
await browser.close();
|
|
127
|
+
console.log('β
Done\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
main().catch(e => console.error('π', e));
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@litocodes/persona-test",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "AI User Testing with Personality - Simulate real users breaking your website",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"persona": "./index.js",
|
|
9
|
+
"persona-test": "./index.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"start": "node index.js",
|
|
13
|
+
"test": "node index.js --test",
|
|
14
|
+
"postinstall": "npx playwright install chromium"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"user-testing",
|
|
18
|
+
"ai",
|
|
19
|
+
"playwright",
|
|
20
|
+
"automation",
|
|
21
|
+
"persona",
|
|
22
|
+
"qa",
|
|
23
|
+
"testing",
|
|
24
|
+
"ux"
|
|
25
|
+
],
|
|
26
|
+
"author": "",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18.0.0"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/yourusername/persona-test"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"cheerio": "^1.0.0",
|
|
37
|
+
"dotenv": "^16.4.5",
|
|
38
|
+
"minimist": "^1.2.8",
|
|
39
|
+
"openai": "^4.77.0",
|
|
40
|
+
"playwright": "^1.49.1"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/parser.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// parser.js - With Blacklist Support
|
|
2
|
+
// Tags failed elements as [BROKEN] so AI avoids them
|
|
3
|
+
|
|
4
|
+
import * as cheerio from 'cheerio';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse HTML with blacklist support
|
|
8
|
+
* @param {string} html - Raw HTML
|
|
9
|
+
* @param {number[]} failedIds - IDs that previously failed
|
|
10
|
+
*/
|
|
11
|
+
export function parsePage(html, failedIds = []) {
|
|
12
|
+
const $ = cheerio.load(html);
|
|
13
|
+
const elements = [];
|
|
14
|
+
let idCounter = 1;
|
|
15
|
+
|
|
16
|
+
$('script, style, noscript').remove();
|
|
17
|
+
|
|
18
|
+
// 1. Context
|
|
19
|
+
let contextText = "";
|
|
20
|
+
const pageTitle = $('title').text().trim();
|
|
21
|
+
if (pageTitle) contextText += `[TITLE] ${pageTitle}\n`;
|
|
22
|
+
|
|
23
|
+
$('h1, h2, h3, p, label').each((i, el) => {
|
|
24
|
+
const text = $(el).text().trim().replace(/\s+/g, ' ');
|
|
25
|
+
if (text.length > 5 && text.length < 150) {
|
|
26
|
+
contextText += `[TEXT] ${text.substring(0, 100)}\n`;
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Phone numbers
|
|
31
|
+
const phoneRegex = /(\+?1?[-.\s]?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}|1-800-\w+-\w+)/g;
|
|
32
|
+
const phoneMatches = $('body').text().match(phoneRegex);
|
|
33
|
+
if (phoneMatches) {
|
|
34
|
+
phoneMatches.slice(0, 3).forEach(p => contextText += `[PHONE] ${p.trim()}\n`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
contextText = contextText.substring(0, 1500);
|
|
38
|
+
|
|
39
|
+
// 2. Interactive elements
|
|
40
|
+
$('button, a, input, select, textarea').each((i, el) => {
|
|
41
|
+
const $el = $(el);
|
|
42
|
+
const tagName = $el.prop('tagName')?.toLowerCase() || 'unknown';
|
|
43
|
+
const type = $el.attr('type') || '';
|
|
44
|
+
|
|
45
|
+
if (type === 'hidden') return;
|
|
46
|
+
if ($el.attr('aria-hidden') === 'true') return;
|
|
47
|
+
|
|
48
|
+
let text = $el.text().trim() ||
|
|
49
|
+
$el.attr('placeholder') ||
|
|
50
|
+
$el.attr('aria-label') ||
|
|
51
|
+
$el.attr('name') ||
|
|
52
|
+
$el.attr('value') || '';
|
|
53
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
54
|
+
|
|
55
|
+
let selector = tagName;
|
|
56
|
+
const id = $el.attr('id');
|
|
57
|
+
const name = $el.attr('name');
|
|
58
|
+
const placeholder = $el.attr('placeholder');
|
|
59
|
+
|
|
60
|
+
if (id) selector = `#${id}`;
|
|
61
|
+
else if (name) selector = `${tagName}[name="${name}"]`;
|
|
62
|
+
else if (placeholder) selector = `${tagName}[placeholder="${placeholder}"]`;
|
|
63
|
+
else if (type && tagName === 'input') selector = `input[type="${type}"]`;
|
|
64
|
+
else if (text && tagName !== 'input') selector = `${tagName}:has-text("${text.substring(0, 20)}")`;
|
|
65
|
+
|
|
66
|
+
if (!text && !id && !name && !placeholder) return;
|
|
67
|
+
|
|
68
|
+
elements.push({
|
|
69
|
+
id: idCounter++,
|
|
70
|
+
type: tagName,
|
|
71
|
+
text: text.substring(0, 50),
|
|
72
|
+
selector,
|
|
73
|
+
inputType: type
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// 3. Build map with BROKEN tags
|
|
78
|
+
const interactiveMap = elements.map(el => {
|
|
79
|
+
const broken = failedIds.includes(el.id) ? ' β οΈ [BROKEN - DO NOT USE]' : '';
|
|
80
|
+
|
|
81
|
+
if (el.type === 'input') return `[${el.id}] INPUT (${el.inputType || 'text'}): "${el.text}"${broken}`;
|
|
82
|
+
if (el.type === 'textarea') return `[${el.id}] TEXTAREA: "${el.text}"${broken}`;
|
|
83
|
+
if (el.type === 'select') return `[${el.id}] DROPDOWN: "${el.text}"${broken}`;
|
|
84
|
+
if (el.type === 'a') return `[${el.id}] LINK: "${el.text}"${broken}`;
|
|
85
|
+
return `[${el.id}] BUTTON: "${el.text}"${broken}`;
|
|
86
|
+
}).join('\n');
|
|
87
|
+
|
|
88
|
+
const domMap = `
|
|
89
|
+
--- PAGE CONTEXT ---
|
|
90
|
+
${contextText || '(empty)'}
|
|
91
|
+
--------------------
|
|
92
|
+
--- INTERACTIVE ---
|
|
93
|
+
${interactiveMap || '(none)'}
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
return { domMap, elements, elementCount: elements.length };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function findElementById(elements, id) {
|
|
100
|
+
return elements.find(el => el.id === id) || null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default { parsePage, findElementById };
|
package/personas.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persona Profiles for AI-driven user testing
|
|
3
|
+
* Each persona has unique behaviors, limitations, and goals
|
|
4
|
+
* Optimized for text-based DOM parsing (no vision)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const PERSONAS = {
|
|
8
|
+
boomer: {
|
|
9
|
+
name: "Grandpa Joe",
|
|
10
|
+
age: 70,
|
|
11
|
+
behavior: `70 years old. Low tech literacy.
|
|
12
|
+
Gets confused by modern UI with too many options.
|
|
13
|
+
Looking for obvious things like 'Contact', 'Help', or 'Phone'.
|
|
14
|
+
Prefers simple, clear labels over icons.
|
|
15
|
+
Quits if overwhelmed by too many choices.
|
|
16
|
+
Never clicks on anything that seems "fancy" or unclear.`,
|
|
17
|
+
goal: "Find a phone number or contact information to call.",
|
|
18
|
+
maxActions: 10,
|
|
19
|
+
frustrationThreshold: 5
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
zoomer: {
|
|
23
|
+
name: "Zoomer Zoe",
|
|
24
|
+
age: 20,
|
|
25
|
+
behavior: `20 years old. Extremely impatient.
|
|
26
|
+
Clicks the first interesting thing immediately.
|
|
27
|
+
Ignores instructions and fine print completely.
|
|
28
|
+
Prefers 'Sign Up', 'Get Started', 'Join' buttons.
|
|
29
|
+
Loves social login (Google, Apple, etc).
|
|
30
|
+
Gets annoyed if there are too many form fields.
|
|
31
|
+
KNOWS to fill out username/password fields BEFORE clicking submit.
|
|
32
|
+
Types fast with occasional typos. Uses simple usernames like "zoomer123".
|
|
33
|
+
Scrolls fast, barely reads anything.`,
|
|
34
|
+
goal: "Sign up or find the most interesting thing as fast as possible.",
|
|
35
|
+
maxActions: 10,
|
|
36
|
+
frustrationThreshold: 4
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
skeptic: {
|
|
40
|
+
name: "Skeptical Sam",
|
|
41
|
+
age: 40,
|
|
42
|
+
behavior: `40 years old. Paranoid about privacy.
|
|
43
|
+
Always looks for 'Privacy Policy' and 'Terms' links first.
|
|
44
|
+
Refuses to click 'Accept Cookies' - looks for 'Reject' or 'Manage'.
|
|
45
|
+
Reads the fine print before doing anything.
|
|
46
|
+
Distrusts sites without clear contact info.
|
|
47
|
+
Never uses social login - too much data sharing.
|
|
48
|
+
Looks for security indicators and trust badges.`,
|
|
49
|
+
goal: "Find and read the Privacy Policy, reject cookies, evaluate trustworthiness.",
|
|
50
|
+
maxActions: 12,
|
|
51
|
+
frustrationThreshold: 8
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
hacker: {
|
|
55
|
+
name: "Hacker Harry",
|
|
56
|
+
age: 30,
|
|
57
|
+
behavior: `Security researcher mindset.
|
|
58
|
+
Looks for INPUT fields to test with injection strings.
|
|
59
|
+
Types test payloads like: ' OR '1'='1' --
|
|
60
|
+
Also tries: <script>alert(1)</script>
|
|
61
|
+
Methodically tests each input field found.
|
|
62
|
+
Looks for hidden forms, admin links, or unusual endpoints.
|
|
63
|
+
Very patient and thorough.`,
|
|
64
|
+
goal: "Find all input fields and test them with SQL injection strings.",
|
|
65
|
+
maxActions: 15,
|
|
66
|
+
frustrationThreshold: 10,
|
|
67
|
+
testStrings: {
|
|
68
|
+
sql: "' OR '1'='1' --",
|
|
69
|
+
xss: "<script>alert('XSS')</script>",
|
|
70
|
+
traversal: "../../etc/passwd"
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
explorer: {
|
|
75
|
+
name: "Explorer Emma",
|
|
76
|
+
age: 35,
|
|
77
|
+
behavior: `Curious user who wants to understand everything.
|
|
78
|
+
Clicks on every main navigation link.
|
|
79
|
+
Reads headings and summaries.
|
|
80
|
+
Takes note of what each section contains.
|
|
81
|
+
Methodically explores the entire site structure.
|
|
82
|
+
Creates a mental map of the site.`,
|
|
83
|
+
goal: "Explore the entire site and understand its structure.",
|
|
84
|
+
maxActions: 20,
|
|
85
|
+
frustrationThreshold: 15
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get a persona by name (case-insensitive)
|
|
91
|
+
* @param {string} name - The persona name
|
|
92
|
+
* @returns {object|null} The persona object or null if not found
|
|
93
|
+
*/
|
|
94
|
+
export function getPersona(name) {
|
|
95
|
+
const key = name.toLowerCase();
|
|
96
|
+
return PERSONAS[key] || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* List all available persona names
|
|
101
|
+
* @returns {string[]} Array of persona names
|
|
102
|
+
*/
|
|
103
|
+
export function listPersonas() {
|
|
104
|
+
return Object.keys(PERSONAS);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default PERSONAS;
|