Skip to content

Commit ba9e0d5

Browse files
Add friction behaviors: chaos mode, dead clicks, form mistakes, network throttling (#25)
1 parent 499ea7a commit ba9e0d5

File tree

8 files changed

+462
-14
lines changed

8 files changed

+462
-14
lines changed

index.d.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,17 @@ export interface MeepleParams {
124124
sequences?: SequencesSpec | null;
125125
/** Unique identifier for this simulation run */
126126
runId?: string;
127+
128+
// ── Friction Behaviors ──
129+
130+
/** Simulate poor network conditions via CDP throttling */
131+
networkProfile?: 'fast' | 'moderate' | 'slow3g' | 'slow4g' | 'offline';
132+
/** Enable Chaos Mode: randomly sabotage POST/PUT/PATCH requests + dead clicks */
133+
chaosMode?: boolean;
134+
/** Probability (0-1) that a data request will fail in chaos mode. Default: 0.15 */
135+
chaosFailRate?: number;
136+
/** Enable intentional form mistakes: meeples submit wrong data, trigger validation, then correct */
137+
formMistakes?: boolean;
127138
}
128139

129140
export interface SequencesSpec {
@@ -251,6 +262,17 @@ export interface MeepleOptions {
251262
sequence?: SequenceSpec;
252263
/** Name of the assigned sequence */
253264
sequenceName?: string;
265+
266+
// ── Friction Behaviors ──
267+
268+
/** Network throttling profile */
269+
networkProfile?: 'fast' | 'moderate' | 'slow3g' | 'slow4g' | 'offline';
270+
/** Enable Chaos Mode */
271+
chaosMode?: boolean;
272+
/** Chaos mode fail rate (0-1) */
273+
chaosFailRate?: number;
274+
/** Enable intentional form mistakes */
275+
formMistakes?: boolean;
254276
}
255277

256278
export type LogFunction = (message: string, meepleId?: string | null) => void;

meeple/browser.js

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,96 @@ export async function launchBrowser(headless = true, log = console.log) {
110110
}
111111
}
112112

113+
/**
114+
* Apply CDP network throttling to simulate poor network conditions
115+
* @param {Page} page - Puppeteer page object
116+
* @param {string} profile - Network profile: 'fast' | 'slow3g' | 'slow4g' | 'offline'
117+
* @param {Function} log - Logging function
118+
*/
119+
export async function applyNetworkThrottling(page, profile = 'fast', log = console.log) {
120+
if (!profile || profile === 'fast') return;
121+
122+
try {
123+
const client = await page.createCDPSession();
124+
await client.send('Network.enable');
125+
126+
const profiles = {
127+
slow3g: {
128+
offline: false,
129+
downloadThroughput: ((500 * 1000) / 8) * 0.8, // 500 kbps
130+
uploadThroughput: ((500 * 1000) / 8) * 0.8,
131+
latency: 400 * 5 // 2000ms
132+
},
133+
slow4g: {
134+
offline: false,
135+
downloadThroughput: ((4000 * 1000) / 8) * 0.8, // 4 Mbps
136+
uploadThroughput: ((3000 * 1000) / 8) * 0.8,
137+
latency: 100 * 4 // 400ms
138+
},
139+
moderate: {
140+
offline: false,
141+
downloadThroughput: ((2000 * 1000) / 8) * 0.8, // 2 Mbps
142+
uploadThroughput: ((1000 * 1000) / 8) * 0.8,
143+
latency: 150 // 150ms
144+
},
145+
offline: {
146+
offline: true,
147+
downloadThroughput: 0,
148+
uploadThroughput: 0,
149+
latency: 0
150+
}
151+
};
152+
153+
const config = profiles[profile];
154+
if (!config) {
155+
log(`⚠️ Unknown network profile "${profile}", skipping throttling`);
156+
return;
157+
}
158+
159+
await client.send('Network.emulateNetworkConditions', config);
160+
const labels = { slow3g: 'Slow 3G', slow4g: 'Slow 4G', moderate: 'Moderate (2 Mbps)', offline: 'Offline' };
161+
const label = labels[profile] || profile;
162+
log(`📶 <span style="color: #F8BC3B;">Network throttled to ${label}</span>`);
163+
} catch (error) {
164+
log(`⚠️ Network throttling failed: ${error.message}`);
165+
}
166+
}
167+
168+
/**
169+
* Enable Chaos Mode: randomly sabotage POST/PUT/PATCH requests and intercept fetch/XHR
170+
* @param {Page} page - Puppeteer page object
171+
* @param {number} failRate - Probability (0-1) that a data request will be sabotaged
172+
* @param {Function} log - Logging function
173+
*/
174+
export async function enableChaosMode(page, failRate = 0.15, log = console.log) {
175+
try {
176+
await page.setRequestInterception(true);
177+
page.on('request', request => {
178+
const isMixpanelRequest = request.url().includes('mixpanel') || request.url().includes('mxpnl') || request.url().includes('express-proxy-lmozz6xkha-uc.a.run.app');
179+
const isDataRequest =
180+
!isMixpanelRequest &&
181+
['POST', 'PUT', 'PATCH'].includes(request.method()) &&
182+
['xhr', 'fetch'].includes(request.resourceType());
183+
184+
if (isDataRequest && Math.random() < failRate) {
185+
const statusCode = Math.random() < 0.5 ? 500 : 503;
186+
const errorMsg = statusCode === 500 ? 'Internal Server Error' : 'Service Unavailable';
187+
log(`😈 <span style="color: #CC332B;">Chaos Meeple sabotaged:</span> ${request.method()} ${request.url().substring(0, 80)}`);
188+
request.respond({
189+
status: statusCode,
190+
contentType: 'application/json',
191+
body: JSON.stringify({ error: `Simulated ${errorMsg}` })
192+
});
193+
} else {
194+
request.continue();
195+
}
196+
});
197+
log(`😈 <span style="color: #FF7557;">Chaos Mode enabled</span> (${(failRate * 100).toFixed(0)}% fail rate on data requests)`);
198+
} catch (error) {
199+
log(`⚠️ Chaos mode setup failed: ${error.message}`);
200+
}
201+
}
202+
113203
/**
114204
* Create a new page with realistic user agent and configuration
115205
* @param {Browser} browser - Browser instance
@@ -189,8 +279,9 @@ export async function createPage(browser, log = console.log) {
189279
* @param {Function} log - Logging function
190280
* @returns {Promise<any>} - Navigation response
191281
*/
192-
export async function navigateToUrl(page, url, log = console.log) {
282+
export async function navigateToUrl(page, url, log = console.log, opts = {}) {
193283
const maxRetries = 2;
284+
const slowNetwork = opts.networkProfile && opts.networkProfile !== 'fast';
194285
let lastError;
195286

196287
for (let attempt = 1; attempt <= maxRetries; attempt++) {
@@ -204,7 +295,7 @@ export async function navigateToUrl(page, url, log = console.log) {
204295
const response = await page.goto(url, {
205296
// @ts-ignore
206297
waitUntil,
207-
timeout: 30000 // 30 sec
298+
timeout: slowNetwork ? 90000 : 30000
208299
});
209300

210301
if (response && !response.ok()) {

meeple/forms.js

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,111 @@ export async function fillRadioGroup(page, radioGroupElement, clicksCount = 2, l
211211
}
212212
}
213213

214+
/**
215+
* Fill a text input with an intentional mistake, trigger validation, then correct it.
216+
* Simulates real users making typos in email/URL fields and hitting validation errors.
217+
* @param {Page} page - Puppeteer page object
218+
* @param {ElementHandle} element - The input/textarea element
219+
* @param {string} text - Correct text to ultimately type
220+
* @param {Function} log - Logging function
221+
* @returns {Promise<boolean>} Success status
222+
*/
223+
export async function fillTextInputWithMistake(page, element, text = null, log = console.log) {
224+
try {
225+
await element.scrollIntoViewIfNeeded();
226+
await sleep(randomBetween(100, 300));
227+
228+
// Get element info to decide what kind of mistake to make
229+
const elementInfo = await page.evaluate(
230+
el => ({
231+
type: el.type || 'text',
232+
placeholder: el.placeholder || '',
233+
tagName: el.tagName.toLowerCase()
234+
}),
235+
element
236+
);
237+
238+
// Use provided text or select from test data
239+
let correctText = text;
240+
if (!correctText) {
241+
const termType = ['email', 'search', 'password', 'url', 'tel', 'number'].includes(elementInfo.type)
242+
? elementInfo.type
243+
: 'text';
244+
const availableTerms = formTestData[termType] || formTestData.text;
245+
correctText = availableTerms[Math.floor(Math.random() * availableTerms.length)];
246+
}
247+
248+
// Generate a mistake based on the field type
249+
let wrongText;
250+
const mistakeType = Math.random();
251+
252+
if (elementInfo.type === 'email' && correctText.includes('@')) {
253+
// Remove the @ from an email — triggers HTML5 validation
254+
wrongText = correctText.replace('@', '');
255+
log(` └─ 🤦 <span style="color: #F8BC3B;">Intentional mistake:</span> missing @ in email`);
256+
} else if (elementInfo.type === 'url' && correctText.startsWith('http')) {
257+
// Drop the protocol — triggers validation
258+
wrongText = correctText.replace(/^https?:\/\//, '');
259+
log(` └─ 🤦 <span style="color: #F8BC3B;">Intentional mistake:</span> missing protocol in URL`);
260+
} else if (elementInfo.type === 'tel') {
261+
// Add letters to a phone number
262+
wrongText = correctText.replace(/\d{2}/, 'ab');
263+
log(` └─ 🤦 <span style="color: #F8BC3B;">Intentional mistake:</span> letters in phone number`);
264+
} else if (mistakeType < 0.5) {
265+
// Truncate the text (user didn't finish typing)
266+
wrongText = correctText.substring(0, Math.max(2, Math.floor(correctText.length * 0.4)));
267+
log(` └─ 🤦 <span style="color: #F8BC3B;">Intentional mistake:</span> incomplete input`);
268+
} else {
269+
// Swap two adjacent characters
270+
const swapIdx = Math.floor(Math.random() * (correctText.length - 1));
271+
wrongText =
272+
correctText.substring(0, swapIdx) +
273+
correctText[swapIdx + 1] +
274+
correctText[swapIdx] +
275+
correctText.substring(swapIdx + 2);
276+
log(` └─ 🤦 <span style="color: #F8BC3B;">Intentional mistake:</span> transposed characters`);
277+
}
278+
279+
// Type the wrong text
280+
await element.click({ clickCount: 3 });
281+
await sleep(randomBetween(50, 100));
282+
for (const char of wrongText) {
283+
await page.keyboard.type(char);
284+
await sleep(randomBetween(25, 75));
285+
}
286+
287+
// Try to submit to trigger validation error
288+
await page.keyboard.press('Enter');
289+
await sleep(randomBetween(500, 1500)); // Stare at the error
290+
291+
// Tab away and back (another common pattern to trigger blur validation)
292+
await page.keyboard.press('Tab');
293+
await sleep(randomBetween(300, 800));
294+
295+
log(` └─ 😤 <span style="color: #CC332B;">Validation error triggered</span> — meeple correcting...`);
296+
297+
// Fix it — select all and retype correctly
298+
await element.click({ clickCount: 3 });
299+
await sleep(randomBetween(100, 200));
300+
for (const char of correctText) {
301+
await page.keyboard.type(char);
302+
await sleep(randomBetween(25, 75));
303+
}
304+
305+
await sleep(randomBetween(200, 500));
306+
log(` └─ ✅ <span style="color: #07B096;">Corrected input</span>`);
307+
return true;
308+
} catch (error) {
309+
log(` └─ ⚠️ <span style="color: #F8BC3B;">Form mistake simulation failed:</span> ${error.message}`);
310+
return false;
311+
}
312+
}
313+
214314
/**
215315
* Intelligently fill any form element based on its type
216316
* @param {Page} page - Puppeteer page object
217317
* @param {ElementHandle} element - The form element
218-
* @param {Object} options - Options like {text, value, clicksPerGroup}
318+
* @param {Object} options - Options like {text, value, clicksPerGroup, formMistakes}
219319
* @param {Function} log - Logging function
220320
* @returns {Promise<boolean>} Success status
221321
*/
@@ -237,6 +337,10 @@ export async function fillFormElement(page, element, options = {}, log = console
237337
} else if (elementInfo.tagName === 'select') {
238338
return await fillSelectDropdown(page, element, options.value, log);
239339
} else if (elementInfo.tagName === 'textarea' || elementInfo.tagName === 'input') {
340+
// When formMistakes is enabled, 30% chance to make an intentional mistake
341+
if (options.formMistakes && Math.random() < 0.3) {
342+
return await fillTextInputWithMistake(page, element, options.text, log);
343+
}
240344
return await fillTextInput(page, element, options.text, log);
241345
}
242346

@@ -250,8 +354,11 @@ export async function fillFormElement(page, element, options = {}, log = console
250354
/**
251355
* Interact with forms - search boxes, email inputs, etc.
252356
* ENHANCED: Now supports ALL form element types including radios and checkboxes
357+
* @param {Page} page - Puppeteer page object
358+
* @param {Function} log - Logging function
359+
* @param {Object} [opts] - Options like { formMistakes: boolean }
253360
*/
254-
export async function interactWithForms(page, log = console.log) {
361+
export async function interactWithForms(page, log = console.log, opts = {}) {
255362
try {
256363
// Check if page is still responsive
257364
await page.evaluate(() => document.readyState);
@@ -386,7 +493,11 @@ export async function interactWithForms(page, log = console.log) {
386493
action = `🔽 <span style="color: #9B59B6;">Select option chosen</span>`;
387494
} else {
388495
// Text input, textarea, or other input types
389-
success = await fillTextInput(page, elementHandle, null, log);
496+
if (opts.formMistakes && Math.random() < 0.3) {
497+
success = await fillTextInputWithMistake(page, elementHandle, null, log);
498+
} else {
499+
success = await fillTextInput(page, elementHandle, null, log);
500+
}
390501

391502
// Sometimes submit (30%), sometimes just leave it
392503
if (Math.random() < 0.3) {

0 commit comments

Comments
 (0)