Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/generator-initial-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Initial release of `@humanjs/generator` — a visual recorder that turns a real browsing session into a clean, humanized Playwright test.

`npx @humanjs/generator <url>` opens a real Chromium window and a local (loopback-only) dashboard. As you click, type, scroll, drag, and navigate, each action streams into the dashboard as a step captured with a role-first selector (ARIA role + accessible name → label → text → test id → `#id` → CSS → XPath). The dashboard is a full editor:
`npx @humanjs/generator <url>` opens a real Chromium window and a local (loopback-only) dashboard. As you click, type, scroll, drag, select text, and navigate, each action streams into the dashboard as a step captured with a role-first selector (ARIA role + accessible name → label → text → test id → `#id` → CSS → XPath). The dashboard is a full editor:

- **drag to reorder**, delete, relabel (label → comment), and edit captured values
- a per-step **selector picker** over the ranked candidates
Expand Down
11 changes: 11 additions & 0 deletions .changeset/select-text-primitive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@humanjs/playwright": minor
"@humanjs/core": minor
"@humanjs/mcp": minor
"@humanjs/generator": patch
"@humanjs/skill": patch
---

Add `human.selectText(target, options?)` — highlight text inside an element. The cursor moves to the element (humanized), then the text is selected — the "select this" gesture before copying, replacing, or triggering a highlight menu. Selects the element's whole text by default; pass `{ text }` to select just that substring, located inside the element whitespace-tolerantly and mapped to exact offsets (first match, falling back to the whole element if not found) — so it's reproduced by the text itself, not brittle coordinates. In `speed: 'instant'` the cursor motion is skipped; the selection is still applied.

Mirrored as the **`human_selectText`** MCP tool (with the optional `text` arg), rendered by the recorder code generators (`toPlaywright` / `toHumanJS`), documented in the `@humanjs/skill` primitives table, and backed by a new `'selectText'` `KnownActionType` in `@humanjs/core`. `@humanjs/generator` captures the gesture too: highlighting an element's whole text records a plain `selectText`, and highlighting part of it records `selectText(target, { text })` with the exact substring.
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ await human.clear(selector); // wipe a field: select-all + delete (
await human.check(selector); // tick a checkbox/radio (clicks only if needed)
await human.uncheck(selector); // untick a checkbox
await human.selectOption(selector, value);// native <select> — cursor moves to it, then sets value
await human.selectText(selector); // highlight an element's text (cursor moves to it, then selects)
await human.selectText(selector, { text: 'es una' }); // select just a substring (found inside the element)
await human.upload(selector, files); // attach file(s) to a file input (no OS dialog)
await human.read(text); // dwell based on word count
await human.scroll('natural');
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ ghost-cursor pioneered humanized mouse paths and is excellent at what it does. H
- [x] MCP server (`@humanjs/mcp`) for AI agents
- [x] Recorder code export (Playwright / HumanJS)
- [x] AI coding-agent skill (`@humanjs/skill`)
- [ ] Visual generator (`@humanjs/generator`)
- [x] Visual generator (`@humanjs/generator`)
- [x] Plugin system + community personality authoring (`@yourname/personality-*`)
- [ ] Recipes (`@humanjs/recipes`) for common flows
- [ ] Touch / mobile humanization
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"framer-motion": "^12.40.0",
"lucide-react": "^1.18.0",
"next": "^16.2.6",
"react": "^19.2.6",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export type KnownActionType =
| 'rightClick'
| 'scroll'
| 'selectOption'
| 'selectText'
| 'press'
| 'sleep'
| 'type'
Expand Down
2 changes: 1 addition & 1 deletion packages/generator/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Each adds a real subsystem, so they stay out of the first cut:
- **In-app replay / verify** — a "run this" button that executes the exported spec (spawns `playwright test`) and shows pass / fail in the dashboard, closing the record → test → green loop.
- **Video + GIF export** — capture frames alongside the DOM events so the same session also exports an `.mp4` / `.gif` via the existing `toVideo` / `toGif` path. Reuses recorder infra; great for the demo / tutorial-creator audience. Needs the frame-capture loop running next to DOM capture.
- **Insert / re-record mid-timeline** — append or splice new actions into an existing recording without starting over.
- **`human.selectText(target)` primitive + capture** — a combined unit done together: add the element-scoped text-selection primitive to `@humanjs/core` + `@humanjs/playwright` (wrapping Playwright's `locator.selectText()`) + the MCP tool + codegen support + a changeset, **and** the small recorder.ts wiring to capture it. Scope: **element-scoped only** (anchor + focus inside one element covering its text). Free-form cross-element range selection stays uncaptured by design — it's offset/coordinate-based and would generate a brittle, reflow-fragile step, against HumanJS's selectors-over-coordinates philosophy. Until then, the recorder's text-selection guard correctly keeps highlighting from producing a bogus `drag`.
- ~~**`human.selectText(target)` primitive + capture**~~_shipped._ Text-selection primitive (`@humanjs/core` + `@humanjs/playwright` + MCP + codegen) plus recorder capture. Whole-element highlights record a plain `selectText`; partial highlights record `selectText(target, { text })` with the exact substring, reproduced by finding that text inside the element (not by coordinates).

## Deliberately out of scope

Expand Down
4 changes: 2 additions & 2 deletions packages/generator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@
"@vitejs/plugin-react": "^4.3.4",
"framer-motion": "^12.40.0",
"happy-dom": "^15.11.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"vite": "^6.0.7"
},
"publishConfig": {
Expand Down
80 changes: 78 additions & 2 deletions packages/generator/src/injected/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,27 @@ export function installRecorder(): void {
// captured as a separate step.
let suppressNextClick = false;

// A click on non-interactive content might be the leading click of a triple-
// click whose selection hasn't formed yet. We defer such clicks briefly so a
// following multi-click or `selectText` can retract them; if nothing does, the
// click emits on its own. Any other recorded action flushes it first, so order
// is preserved.
let pendingClickParams: Record<string, unknown> | null = null;
let pendingClickTimer: ReturnType<typeof setTimeout> | undefined;
function flushPendingClick(): void {
clearTimeout(pendingClickTimer);
pendingClickTimer = undefined;
if (!pendingClickParams) return;
const params = pendingClickParams;
pendingClickParams = null;
emit({ type: 'click', params });
}
function cancelPendingClick(): void {
clearTimeout(pendingClickTimer);
pendingClickTimer = undefined;
pendingClickParams = null;
}

// Throttled "a gesture happened" ping. Lets the CLI tell a navigation caused
// by interaction (clicked link, form submit, search-as-you-type) from a
// user-driven one (address bar) — only the latter becomes a `goto` step.
Expand Down Expand Up @@ -164,8 +185,9 @@ export function installRecorder(): void {
emit({ type: 'type', params, inputValue: masked ? undefined : value });
}

/** Flush any pending typed value, then emit a discrete action in order. */
/** Flush any pending deferred click + typed value, then emit in order. */
function emitAction(action: CapturedAction): void {
flushPendingClick();
flushType();
emit(action);
}
Expand Down Expand Up @@ -199,12 +221,34 @@ export function installRecorder(): void {
'click',
(event) => {
if (event.button !== 0) return;
// The 2nd+ click of a multi-click is a word/paragraph selection gesture
// (captured as `selectText`); it also retracts the leading click deferred
// below.
if (event.detail >= 2) {
cancelPendingClick();
return;
}
if (suppressNextClick) {
suppressNextClick = false;
return;
}
const el = resolveActionable(event.target);
if (el) emitAction({ type: 'click', params: targetParams(el) });
if (!el) return;
// Interactive targets are always a real click — record immediately.
if (el.matches(ACTIONABLE)) {
emitAction({ type: 'click', params: targetParams(el) });
return;
}
// Non-interactive target. While text is already selected, this click is
// the tail of a drag-select — drop it (captured as `selectText`).
const selection = window.getSelection();
if (selection && !selection.isCollapsed) return;
// Otherwise it may be the leading click of a triple-click (the selection
// hasn't formed yet). Defer it so a following multi-click / `selectText`
// can cancel it; otherwise it emits on its own shortly.
flushPendingClick();
pendingClickParams = targetParams(el);
pendingClickTimer = setTimeout(flushPendingClick, 400);
},
true,
);
Expand Down Expand Up @@ -382,4 +426,36 @@ export function installRecorder(): void {
},
{ passive: true, capture: true },
);

// Text selection: capture a highlight (drag-select, triple-click, select-all)
// as a selectText step. The target is the smallest element containing the
// selection; if the highlight is that element's whole text we record a plain
// selectText, otherwise we record the exact substring so replay re-selects
// just it (`selectText(el, { text })`). (Input/textarea internal selection
// lives off the document selection, so it isn't auto-captured.)
const normalizeText = (value: string | null | undefined): string =>
(value ?? '').replace(/\s+/g, ' ').trim();
let selectionTimer: ReturnType<typeof setTimeout> | undefined;
document.addEventListener('selectionchange', () => {
clearTimeout(selectionTimer);
// Debounce until the selection settles — a drag-select fires this per pixel.
selectionTimer = setTimeout(() => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
const selectedRaw = selection.toString().trim();
const selected = normalizeText(selectedRaw);
if (!selected) return;
const container = selection.getRangeAt(0).commonAncestorContainer;
const el = container instanceof Element ? container : container.parentElement;
if (!el || el === document.body || el === document.documentElement) return;
const whole = normalizeText(el.textContent);
if (!whole) return;
// Whole-element highlight → plain selectText; partial → carry the text.
const params =
whole === selected ? targetParams(el) : { ...targetParams(el), text: selectedRaw };
// The selection formed → retract the leading click we deferred for it.
cancelPendingClick();
emitAction({ type: 'selectText', params });
}, 400);
});
}
23 changes: 23 additions & 0 deletions packages/mcp/src/tools/primitives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,29 @@ export function registerPrimitiveTools(server: McpServer, { sessions, env }: Too
},
);

server.registerTool(
'human_selectText',
{
title: 'Select an element’s text (humanized)',
description:
'Selects (highlights) text inside an element — moves the cursor to it, then selects. By default selects all of the element’s text; pass `text` to select just that substring (found inside the element, whitespace-tolerant, first match; falls back to the whole element if not found). Use before copying, replacing, or triggering a highlight menu.',
inputSchema: {
selector: z.string().describe('Selector of the element whose text to select.'),
text: z
.string()
.optional()
.describe('Optional substring to select instead of the element’s whole text.'),
session: sessionArg,
},
},
async ({ selector, text, session }) => {
const { human } = await sessions.get(session);
await human.selectText(selector, text === undefined ? undefined : { text });
const what = text === undefined ? 'text' : `"${text}"`;
return { content: [{ type: 'text', text: `selected ${what} in ${selector}` }] };
},
);

server.registerTool(
'human_check',
{
Expand Down
54 changes: 54 additions & 0 deletions packages/playwright/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type SelectOptionValues,
type UploadFiles,
} from './forms';
import { selectSubstringInElement } from './internal/select-substring';
import { executeClear, executePaste, executePress, executeType, type KeyOrChord } from './keyboard';
import { executeClick, executeDrag, executeHover, executeMove, type MouseTarget } from './mouse';
import { type InstallMouseHelperOptions, installMouseHelper } from './mouse-helper';
Expand Down Expand Up @@ -115,6 +116,16 @@ export {
} from './recording';
export type { ScrollOptions, ScrollResult, ScrollTarget } from './scroll';

/** Options for {@link Human.selectText}. */
export interface SelectTextOptions {
/**
* Select only this substring of the element's text instead of all of it.
* Located inside the element whitespace-tolerantly (first match); falls back
* to selecting the whole element when not found.
*/
text?: string;
}

/**
* How fast the humanized session runs.
* - `'human'` — full humanization (default)
Expand Down Expand Up @@ -312,6 +323,23 @@ export interface Human {
* In `speed: 'instant'`, sets the value with no cursor motion.
*/
selectOption(target: Locator | string, values: SelectOptionValues): Promise<string[]>;
/**
* Select text inside `target` (a paragraph, heading, input, …). The cursor
* moves to the element (humanized), then the text is highlighted — the
* "select this" gesture before copying, replacing, or triggering a highlight
* menu.
*
* By default the element's whole text is selected. Pass `{ text }` to select
* just that substring: HumanJS finds it inside the element (whitespace-
* tolerant, mapped to exact offsets, first match) and selects only that
* range — so a recorded partial highlight reproduces as itself, not the whole
* element. If the text can't be located, it falls back to selecting all of
* the element.
*
* `target` is element-bound (selector or `Locator`). In `speed: 'instant'`
* the cursor motion is skipped; the selection is still applied.
*/
selectText(target: Locator | string, options?: SelectTextOptions): Promise<void>;
/**
* Attach file(s) to a file-input `target`. The cursor moves to the control
* (visible in recordings / the overlay), then the files are attached via
Expand Down Expand Up @@ -905,6 +933,32 @@ export async function createHuman(page: Page, options: CreateHumanOptions = {}):
() => executeSelectOption(target, values, mouseCtx()),
);
},
async selectText(target, options) {
const text = options?.text;
await performAction(
{
type: 'selectText',
params: { target: describeMouseTarget(target), ...(text !== undefined ? { text } : {}) },
},
async () => {
// Move the cursor onto the element first (the visible "select this"
// approach), then highlight its text. Skipped in instant mode, where
// the selection is set with no motion.
if (speed !== 'instant') {
await executeMove(target, mouseCtx());
}
const locator = typeof target === 'string' ? page.locator(target) : target;
if (text === undefined) {
await locator.selectText();
return;
}
// Substring select: find the text in the element and select just it,
// falling back to the whole element when it can't be located.
const found = await locator.evaluate(selectSubstringInElement, text);
if (!found) await locator.selectText();
},
);
},
async upload(target, files) {
await performAction(
{
Expand Down
63 changes: 63 additions & 0 deletions packages/playwright/src/internal/select-substring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/// <reference lib="dom" />

/**
* Selects the first occurrence of `needleRaw` inside `el`'s text and returns
* whether it was found. Runs in the browser via `locator.evaluate`, so it has
* to be self-contained (no imports / closures).
*
* Matching is whitespace-normalized — the recorded text collapses runs of
* whitespace, and real markup is full of incidental spacing — but the match is
* mapped back to exact text-node offsets, so a `Range` that spans inline
* children (e.g. selecting across a `<b>` inside a `<p>`) still selects
* correctly. Returns `false` when the text isn't present, letting the caller
* fall back to selecting the element's whole text.
*/
export function selectSubstringInElement(el: Element, needleRaw: string): boolean {
const needle = needleRaw.replace(/\s+/g, ' ').trim();
if (!needle) return false;

// Walk the element's text nodes, building a whitespace-collapsed string while
// recording, for every kept character, the source node + offset it came from.
const map: Array<[Text, number]> = [];
let normalized = '';
let prevSpace = true; // start "in space" so leading whitespace collapses away
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = node as Text;
const data = text.data;
for (let i = 0; i < data.length; i++) {
const ch = data.charAt(i);
if (/\s/.test(ch)) {
if (!prevSpace) {
normalized += ' ';
map.push([text, i]);
}
prevSpace = true;
} else {
normalized += ch;
map.push([text, i]);
prevSpace = false;
}
}
}
// Drop a trailing collapsed space so offsets line up with `normalized`.
if (normalized.endsWith(' ')) {
normalized = normalized.slice(0, -1);
map.pop();
}

const start = normalized.indexOf(needle);
if (start === -1) return false;
const startPos = map[start];
const endPos = map[start + needle.length - 1];
if (!startPos || !endPos) return false;

const range = document.createRange();
range.setStart(startPos[0], startPos[1]);
range.setEnd(endPos[0], endPos[1] + 1); // end offset is exclusive
const selection = window.getSelection();
if (!selection) return false;
selection.removeAllRanges();
selection.addRange(range);
return true;
}
18 changes: 18 additions & 0 deletions packages/playwright/src/recording/codegen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,24 @@ describe('generateHumanJS', () => {
expect(out).toContain("await human.move('nav');");
});

it('maps single-target element actions (check / clear / selectText)', () => {
const out = generateHumanJS(
timeline([
ev('check', { target: '#agree' }),
ev('clear', { target: '#name' }),
ev('selectText', { target: 'h1' }),
]),
);
expect(out).toContain("await human.check('#agree');");
expect(out).toContain("await human.clear('#name');");
expect(out).toContain("await human.selectText('h1');");
});

it('renders a partial selectText with its captured substring', () => {
const out = generateHumanJS(timeline([ev('selectText', { target: 'p', text: 'es una' })]));
expect(out).toContain("await human.selectText('p', { text: 'es una' });");
});

it('emits raw points as coordinate objects with a flag comment', () => {
const out = generateHumanJS(timeline([ev('move', { target: 'point(120, 340)' })]));
expect(out).toContain('await human.move({ x: 120, y: 340 });');
Expand Down
Loading