Skip to content

Commit ecee99f

Browse files
author
SentienceDEV
authored
Merge pull request #104 from SentienceAPI/typing
human like typing
2 parents d87569a + cab8f1b commit ecee99f

6 files changed

Lines changed: 257 additions & 7 deletions

File tree

.prettierignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ coverage/
77
*.min.js
88
package-lock.json
99

10+
11+

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,33 @@ await browser.close();
141141

142142
---
143143

144+
## 🆕 What's New (2026-01-06)
145+
146+
### Human-like Typing
147+
Add realistic delays between keystrokes to mimic human typing:
148+
```typescript
149+
// Type instantly (default)
150+
await typeText(browser, elementId, 'Hello World');
151+
152+
// Type with human-like delay (~10ms between keystrokes)
153+
await typeText(browser, elementId, 'Hello World', false, 10);
154+
```
155+
156+
### Scroll to Element
157+
Scroll elements into view with smooth animation:
158+
```typescript
159+
const snap = await snapshot(browser);
160+
const button = find(snap, 'role=button text~"Submit"');
161+
162+
// Scroll element into view with smooth animation
163+
await scrollTo(browser, button.id);
164+
165+
// Scroll instantly to top of viewport
166+
await scrollTo(browser, button.id, 'instant', 'start');
167+
```
168+
169+
---
170+
144171
<details>
145172
<summary><h2>📊 Agent Execution Tracing (NEW in v0.3.1)</h2></summary>
146173

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/actions.ts

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,22 +223,28 @@ export async function click(
223223
* @param elementId - Element ID from snapshot (must be a text input element)
224224
* @param text - Text to type
225225
* @param takeSnapshot - Take snapshot after action (default: false)
226+
* @param delayMs - Delay between keystrokes in milliseconds for human-like typing (default: 0)
226227
* @returns ActionResult with success status, outcome, duration, and optional snapshot
227228
*
228229
* @example
229230
* ```typescript
230231
* const snap = await snapshot(browser);
231232
* const searchBox = find(snap, 'role=searchbox');
232233
* if (searchBox) {
234+
* // Type instantly (default behavior)
233235
* await typeText(browser, searchBox.id, 'Hello World');
236+
*
237+
* // Type with human-like delay (~10ms between keystrokes)
238+
* await typeText(browser, searchBox.id, 'Hello World', false, 10);
234239
* }
235240
* ```
236241
*/
237242
export async function typeText(
238243
browser: IBrowser,
239244
elementId: number,
240245
text: string,
241-
takeSnapshot: boolean = false
246+
takeSnapshot: boolean = false,
247+
delayMs: number = 0
242248
): Promise<ActionResult> {
243249
const page = browser.getPage();
244250
if (!page) {
@@ -270,8 +276,98 @@ export async function typeText(
270276
};
271277
}
272278

273-
// Type using Playwright keyboard
274-
await page.keyboard.type(text);
279+
// Type using Playwright keyboard with optional delay between keystrokes
280+
await page.keyboard.type(text, { delay: delayMs });
281+
282+
const durationMs = Date.now() - startTime;
283+
const urlAfter = page.url();
284+
const urlChanged = urlBefore !== urlAfter;
285+
286+
const outcome = urlChanged ? 'navigated' : 'dom_updated';
287+
288+
let snapshotAfter: Snapshot | undefined;
289+
if (takeSnapshot) {
290+
snapshotAfter = await snapshot(browser);
291+
}
292+
293+
return {
294+
success: true,
295+
duration_ms: durationMs,
296+
outcome,
297+
url_changed: urlChanged,
298+
snapshot_after: snapshotAfter,
299+
};
300+
}
301+
302+
/**
303+
* Scroll an element into view
304+
*
305+
* Scrolls the page so that the specified element is visible in the viewport.
306+
* Uses the element registry to find the element and scrollIntoView() to scroll it.
307+
*
308+
* @param browser - SentienceBrowser instance
309+
* @param elementId - Element ID from snapshot to scroll into view
310+
* @param behavior - Scroll behavior: 'smooth' for animated scroll, 'instant' for immediate (default: 'smooth')
311+
* @param block - Vertical alignment: 'start', 'center', 'end', 'nearest' (default: 'center')
312+
* @param takeSnapshot - Take snapshot after action (default: false)
313+
* @returns ActionResult with success status, outcome, duration, and optional snapshot
314+
*
315+
* @example
316+
* ```typescript
317+
* const snap = await snapshot(browser);
318+
* const button = find(snap, 'role=button[name="Submit"]');
319+
* if (button) {
320+
* // Scroll element into view with smooth animation
321+
* await scrollTo(browser, button.id);
322+
*
323+
* // Scroll instantly to top of viewport
324+
* await scrollTo(browser, button.id, 'instant', 'start');
325+
* }
326+
* ```
327+
*/
328+
export async function scrollTo(
329+
browser: IBrowser,
330+
elementId: number,
331+
behavior: 'smooth' | 'instant' | 'auto' = 'smooth',
332+
block: 'start' | 'center' | 'end' | 'nearest' = 'center',
333+
takeSnapshot: boolean = false
334+
): Promise<ActionResult> {
335+
const page = browser.getPage();
336+
if (!page) {
337+
throw new Error('Browser not started. Call start() first.');
338+
}
339+
const startTime = Date.now();
340+
const urlBefore = page.url();
341+
342+
// Scroll element into view using the element registry
343+
const scrolled = await BrowserEvaluator.evaluate(
344+
page,
345+
(args: { id: number; behavior: string; block: string }) => {
346+
const el = (window as any).sentience_registry[args.id];
347+
if (el && el.scrollIntoView) {
348+
el.scrollIntoView({
349+
behavior: args.behavior,
350+
block: args.block,
351+
inline: 'nearest',
352+
});
353+
return true;
354+
}
355+
return false;
356+
},
357+
{ id: elementId, behavior, block }
358+
);
359+
360+
if (!scrolled) {
361+
return {
362+
success: false,
363+
duration_ms: Date.now() - startTime,
364+
outcome: 'error',
365+
error: { code: 'scroll_failed', reason: 'Element not found or not scrollable' },
366+
};
367+
}
368+
369+
// Wait a bit for scroll to complete (especially for smooth scrolling)
370+
await page.waitForTimeout(behavior === 'smooth' ? 500 : 100);
275371

276372
const durationMs = Date.now() - startTime;
277373
const urlAfter = page.url();

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
export { SentienceBrowser } from './browser';
66
export { snapshot, SnapshotOptions } from './snapshot';
77
export { query, find, parseSelector } from './query';
8-
export { click, typeText, press, clickRect, ClickRect } from './actions';
8+
export { click, typeText, press, scrollTo, clickRect, ClickRect } from './actions';
99
export { waitFor } from './wait';
1010
export { expect, Expectation } from './expect';
1111
export { Inspector, inspect } from './inspector';

tests/actions.test.ts

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,17 @@
22
* Tests for actions (click, type, press, clickRect)
33
*/
44

5-
import { SentienceBrowser, click, typeText, press, clickRect, snapshot, find, BBox } from '../src';
5+
import {
6+
SentienceBrowser,
7+
click,
8+
typeText,
9+
press,
10+
scrollTo,
11+
clickRect,
12+
snapshot,
13+
find,
14+
BBox,
15+
} from '../src';
616
import { createTestBrowser, getPageOrThrow } from './test-utils';
717

818
describe('Actions', () => {
@@ -119,6 +129,121 @@ describe('Actions', () => {
119129
}, 60000);
120130
});
121131

132+
describe('scrollTo', () => {
133+
it('should scroll an element into view', async () => {
134+
const browser = await createTestBrowser();
135+
136+
try {
137+
const page = getPageOrThrow(browser);
138+
await page.goto('https://example.com');
139+
await page.waitForLoadState('networkidle', { timeout: 10000 });
140+
141+
const snap = await snapshot(browser);
142+
// Find an element to scroll to
143+
const elements = snap.elements.filter(el => el.role === 'link');
144+
145+
if (elements.length > 0) {
146+
// Get the last element which might be out of viewport
147+
const element = elements.length > 1 ? elements[elements.length - 1] : elements[0];
148+
const result = await scrollTo(browser, element.id);
149+
expect(result.success).toBe(true);
150+
expect(result.duration_ms).toBeGreaterThan(0);
151+
expect(['navigated', 'dom_updated']).toContain(result.outcome);
152+
}
153+
} finally {
154+
await browser.close();
155+
}
156+
}, 60000);
157+
158+
it('should scroll with instant behavior', async () => {
159+
const browser = await createTestBrowser();
160+
161+
try {
162+
const page = getPageOrThrow(browser);
163+
await page.goto('https://example.com');
164+
await page.waitForLoadState('networkidle', { timeout: 10000 });
165+
166+
const snap = await snapshot(browser);
167+
const elements = snap.elements.filter(el => el.role === 'link');
168+
169+
if (elements.length > 0) {
170+
const element = elements[0];
171+
const result = await scrollTo(browser, element.id, 'instant', 'start');
172+
expect(result.success).toBe(true);
173+
expect(result.duration_ms).toBeGreaterThan(0);
174+
}
175+
} finally {
176+
await browser.close();
177+
}
178+
}, 60000);
179+
180+
it('should take snapshot after scroll when requested', async () => {
181+
const browser = await createTestBrowser();
182+
183+
try {
184+
const page = getPageOrThrow(browser);
185+
await page.goto('https://example.com');
186+
await page.waitForLoadState('networkidle', { timeout: 10000 });
187+
188+
const snap = await snapshot(browser);
189+
const elements = snap.elements.filter(el => el.role === 'link');
190+
191+
if (elements.length > 0) {
192+
const element = elements[0];
193+
const result = await scrollTo(browser, element.id, 'smooth', 'center', true);
194+
expect(result.success).toBe(true);
195+
expect(result.snapshot_after).toBeDefined();
196+
expect(result.snapshot_after?.status).toBe('success');
197+
}
198+
} finally {
199+
await browser.close();
200+
}
201+
}, 60000);
202+
203+
it('should fail for invalid element ID', async () => {
204+
const browser = await createTestBrowser();
205+
206+
try {
207+
const page = getPageOrThrow(browser);
208+
await page.goto('https://example.com');
209+
await page.waitForLoadState('networkidle', { timeout: 10000 });
210+
211+
// Try to scroll to non-existent element
212+
const result = await scrollTo(browser, 99999);
213+
expect(result.success).toBe(false);
214+
expect(result.error).toBeDefined();
215+
expect(result.error?.code).toBe('scroll_failed');
216+
} finally {
217+
await browser.close();
218+
}
219+
}, 60000);
220+
});
221+
222+
describe('typeText with delay', () => {
223+
it('should type text with human-like delay', async () => {
224+
const browser = await createTestBrowser();
225+
226+
try {
227+
const page = getPageOrThrow(browser);
228+
await page.goto('https://example.com');
229+
await page.waitForLoadState('networkidle', { timeout: 10000 });
230+
231+
const snap = await snapshot(browser);
232+
const textbox = find(snap, 'role=textbox');
233+
234+
if (textbox) {
235+
// Test with 10ms delay between keystrokes
236+
const result = await typeText(browser, textbox.id, 'hello', false, 10);
237+
expect(result.success).toBe(true);
238+
// Duration should be longer due to delays (at least 5 chars * 10ms = 50ms)
239+
expect(result.duration_ms).toBeGreaterThanOrEqual(50);
240+
}
241+
} finally {
242+
await browser.close();
243+
}
244+
}, 60000);
245+
});
246+
122247
describe('clickRect', () => {
123248
it('should click at rectangle center using rect dict', async () => {
124249
const browser = await createTestBrowser();

0 commit comments

Comments
 (0)