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
20 changes: 20 additions & 0 deletions .changeset/playwright-test-fixture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@humanjs/playwright": minor
"@humanjs/skill": patch
---

Add a Playwright Test fixture at the `@humanjs/playwright/test` subpath. It extends `@playwright/test`'s `test` with a ready-to-use `human` fixture — bound to the test's `page`, seeded from the test title (deterministic per test), and instant in CI / humanized locally — so specs skip the `createHuman` boilerplate:

```ts
import { test, expect } from '@humanjs/playwright/test';

test('checkout', async ({ human, page }) => {
await human.goto('/cart');
await human.click('Checkout');
await expect(page).toHaveURL(/success/);
});
```

Customize per file or project via `test.use({ humanOptions: { … } })`. `@playwright/test` is an optional peer dependency (only needed for this subpath; the package root is unaffected).

The recorder's `toPlaywright()` code export now generates specs that use this fixture — `import { test, expect } from '@humanjs/playwright/test'` plus `test.use({ humanOptions: … })` carrying the recorded personality/seed/speed — instead of a per-test `createHuman` call. (`toHumanJS()`, the standalone script export, is unchanged.)
7 changes: 6 additions & 1 deletion .github/workflows/playwright-compat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ jobs:
run: pnpm --filter @humanjs/core build

- name: Pin Playwright to matrix version
run: pnpm --filter @humanjs/playwright add -D playwright@${{ matrix.playwright }}
# Pin BOTH playwright and @playwright/test — they ship in lockstep and a
# real install always has them on the same version. The `/test` fixture
# bridges them (its `page` comes from @playwright/test, passed to
# createHuman which types `Page` from playwright), so a version skew here
# would surface a phantom type mismatch that no real user hits.
run: pnpm --filter @humanjs/playwright add -D playwright@${{ matrix.playwright }} @playwright/test@${{ matrix.playwright }}

- name: Typecheck against this Playwright version
run: pnpm --filter @humanjs/playwright typecheck
Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,25 +161,37 @@ Click through your app, pick a personality, export to clean Playwright + HumanJS

## In tests

Use the `@humanjs/playwright/test` fixture — it extends Playwright's `test` with a ready-to-use `human`, seeded from the test title and instant in CI, humanized locally. No boilerplate:

```ts
import { test, expect } from '@humanjs/playwright/test';

test('checkout flow', async ({ human, page }) => {
await human.goto('/');
await human.click('Buy now');
await expect(page).toHaveURL(/checkout/);
});
```

The seed (the test title) makes runs deterministic; `speed: 'instant'` in CI keeps the suite fast, full humanization runs locally. Customize per file or project: `test.use({ humanOptions: { personality: 'distracted' } })`.

Prefer to wire it yourself? Create the human explicitly:

```ts
import { test, expect } from '@playwright/test';
import { createHuman } from '@humanjs/playwright';

test('checkout flow', async ({ page }) => {
const human = await createHuman(page, {
personality: 'careful',
seed: test.info().title,
speed: process.env.CI ? 'instant' : 'human',
});

await human.goto('/');
await human.click('Buy now');
await expect(page).toHaveURL(/checkout/);
});
```

The `seed` makes runs deterministic. `speed: 'instant'` in CI keeps your test suite fast.

## Compared to alternatives

| | HumanJS | Playwright | ghost-cursor |
Expand Down
22 changes: 22 additions & 0 deletions packages/playwright/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,28 @@ await human.type('input[name="email"]', 'gonzalo@example.com');

Pass a `seed` and every random decision (path curvature, typo placement, keystroke jitter) becomes reproducible. Same seed + same personality + same value = same keystrokes.

### Playwright Test fixture

Writing `@playwright/test` specs? Import from the `@humanjs/playwright/test` subpath instead of constructing a `Human` in every test. It extends Playwright's `test` with a `human` fixture — seeded from the test title (deterministic per test) and instant in CI / humanized locally:

```ts
import { test, expect } from '@humanjs/playwright/test';

test('checkout flow', async ({ human, page }) => {
await human.goto('/');
await human.click('Buy now');
await expect(page).toHaveURL(/checkout/);
});
```

Customize per file (or per project via `playwright.config.ts` `use`) with the `humanOptions` option:

```ts
test.use({ humanOptions: { personality: 'distracted', speed: 'human' } });
```

Requires `@playwright/test` (an optional peer — you already have it to run the tests).

### Primitives

The full `Human` surface, at a glance. Each one fires real DOM events through Playwright; the humanization wraps the timing and the path, not the dispatch.
Expand Down
19 changes: 18 additions & 1 deletion packages/playwright/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@
"default": "./dist/index.cjs"
}
},
"./test": {
"import": {
"types": "./dist/test.d.ts",
"default": "./dist/test.js"
},
"require": {
"types": "./dist/test.d.cts",
"default": "./dist/test.cjs"
}
},
"./package.json": "./package.json"
},
"files": [
Expand Down Expand Up @@ -67,12 +77,19 @@
"ffmpeg-static": "^5.2.0"
},
"peerDependencies": {
"playwright": ">=1.49.0"
"playwright": ">=1.49.0",
"@playwright/test": ">=1.49.0"
},
"peerDependenciesMeta": {
"@playwright/test": {
"optional": true
}
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"playwright": "^1.60.0"
}
}
26 changes: 14 additions & 12 deletions packages/playwright/src/recording/codegen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,12 @@ describe('generateHumanJS', () => {
});

describe('generatePlaywrightTest', () => {
it('emits a @playwright/test spec that uses HumanJS', () => {
it('emits a spec using the @humanjs/playwright/test fixture (no createHuman boilerplate)', () => {
const out = generatePlaywrightTest(timeline([ev('click', { target: 'Buy now' })]));
expect(out).toContain("import { test } from '@playwright/test';");
expect(out).toContain("import { createHuman } from '@humanjs/playwright';");
expect(out).toContain("test('recorded session', async ({ page }) => {");
expect(out).toContain('const human = await createHuman(page, {');
expect(out).toContain("import { test } from '@humanjs/playwright/test';");
expect(out).not.toContain('createHuman');
expect(out).toContain('test.use({ humanOptions: {');
expect(out).toContain("test('recorded session', async ({ human }) => {");
expect(out).toContain("await human.click('Buy now');");
expect(out).toContain('});');
});
Expand All @@ -158,7 +158,7 @@ describe('generatePlaywrightTest', () => {
timeline([ev('sleep', { ms: 800 }), ev('click', { target: '#go' })]),
);
expect(out).not.toContain('await sleep(');
expect(out).not.toContain('import { createHuman, sleep }');
expect(out).not.toContain("import { sleep } from '@humanjs/playwright';");
expect(out).toContain("await human.click('#go');");
});

Expand All @@ -168,7 +168,8 @@ describe('generatePlaywrightTest', () => {
{ keepSleeps: true },
);
expect(out).toContain('await sleep(800);');
expect(out).toContain("import { createHuman, sleep } from '@humanjs/playwright';");
expect(out).toContain("import { sleep } from '@humanjs/playwright';");
expect(out).toContain("import { test } from '@humanjs/playwright/test';");
});

it('runs instant in CI / recorded speed locally', () => {
Expand All @@ -180,12 +181,12 @@ describe('generatePlaywrightTest', () => {
const named = generatePlaywrightTest(
timeline([ev('click', { target: '#go' })], { name: 'checkout flow' }),
);
expect(named).toContain("test('checkout flow', async ({ page }) => {");
expect(named).toContain("test('checkout flow', async ({ human }) => {");
const overridden = generatePlaywrightTest(
timeline([ev('click', { target: '#go' })], { name: 'checkout flow' }),
{ title: 'override' },
);
expect(overridden).toContain("test('override', async ({ page }) => {");
expect(overridden).toContain("test('override', async ({ human }) => {");
});

it('derives toBeVisible from reads and toHaveValue from captured inputs', () => {
Expand All @@ -195,7 +196,8 @@ describe('generatePlaywrightTest', () => {
ev('read', { target: '.passage' }),
]),
);
expect(out).toContain("import { expect, test } from '@playwright/test';");
expect(out).toContain("import { expect, test } from '@humanjs/playwright/test';");
expect(out).toContain("test('recorded session', async ({ human, page }) => {");
expect(out).toContain("await human.type('#email', 'a@b.com');");
expect(out).toContain("await expect(page.locator('#email')).toHaveValue('a@b.com');");
expect(out).toContain("await human.read('.passage');");
Expand All @@ -205,9 +207,9 @@ describe('generatePlaywrightTest', () => {

it('omits the expect import (and notes it in the TODO) when nothing is assertable', () => {
const out = generatePlaywrightTest(timeline([ev('click', { target: 'Buy now' })]));
expect(out).toContain("import { test } from '@playwright/test';");
expect(out).toContain("import { test } from '@humanjs/playwright/test';");
expect(out).not.toContain('import { expect, test }');
expect(out).toContain('TODO: assert the outcome — import { expect }');
expect(out).toContain('TODO: assert the outcome — add `page` to the test args');
});

it('does not assert password inputs (no captured value)', () => {
Expand Down
33 changes: 20 additions & 13 deletions packages/playwright/src/recording/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,10 @@ function emitSteps(events: readonly TimelineEvent[], opts: EmitOptions): string
/**
* Generates a `@playwright/test` spec that replays the session through
* HumanJS — a humanized test, not raw Playwright, since the humanization is
* the point. Runs instant in CI / recorded speed locally, drops timing
* `sleep()`s by default, and derives the assertions it safely can.
* the point. Uses the `@humanjs/playwright/test` fixture for the `human`
* (recorded personality / seed / speed applied via `test.use({ humanOptions })`),
* runs instant in CI / recorded speed locally, drops timing `sleep()`s by
* default, and derives the assertions it safely can.
*/
export function generatePlaywrightTest(
timeline: Timeline,
Expand All @@ -296,13 +298,16 @@ export function generatePlaywrightTest(
// Only import `expect` if we actually emitted assertions (body has no
// comments yet, so this won't match the TODO placeholder below).
const hasAsserts = body.includes('await expect(');
const testImport = hasAsserts
? "import { expect, test } from '@playwright/test';"
: "import { test } from '@playwright/test';";
const humanImport = needsSleep
? "import { createHuman, sleep } from '@humanjs/playwright';"
: "import { createHuman } from '@humanjs/playwright';";
// `test` + `expect` come from the `@humanjs/playwright/test` fixture (which
// supplies the `human`); only `sleep` (when kept) stays on the package root.
const fixtureImport = hasAsserts
? "import { expect, test } from '@humanjs/playwright/test';"
: "import { test } from '@humanjs/playwright/test';";
const sleepImport = needsSleep ? "\nimport { sleep } from '@humanjs/playwright';" : '';
const title = options.title ?? timeline.name ?? 'recorded session';
// `page` is only referenced by the derived assertions — omit it from the
// test args when there are none, so the generated test has no unused fixture.
const args = hasAsserts ? '{ human, page }' : '{ human }';
const baseUrlNote = baseOrigin
? ` // Set use.baseURL = ${q(baseOrigin)} in playwright.config.ts for these relative paths.\n\n`
: '';
Expand All @@ -311,16 +316,18 @@ export function generatePlaywrightTest(
const todo = [
hasAsserts
? ' // TODO: add assertions for the outcome of this flow, e.g.:'
: " // TODO: assert the outcome — import { expect } from '@playwright/test', e.g.:",
: " // TODO: assert the outcome — add `page` to the test args and import { expect } from '@humanjs/playwright/test', e.g.:",
' // await expect(page).toHaveURL(/dashboard/);',
" // await expect(page.getByText('Welcome back')).toBeVisible();",
].join('\n');
return `${testImport}
${humanImport}
// Recorded personality / seed / speed are applied via the fixture's
// `humanOptions` option, so the test still replays with its exact settings
// (instant in CI) while skipping the per-test createHuman boilerplate.
return `${fixtureImport}${sleepImport}

test(${q(title)}, async ({ page }) => {
const human = await createHuman(page, ${createHumanOptions(timeline, true)});
test.use({ humanOptions: ${createHumanOptions(timeline, true)} });

test(${q(title)}, async (${args}) => {
${baseUrlNote}${body}

${todo}
Expand Down
18 changes: 18 additions & 0 deletions packages/playwright/src/test/fixture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, it, expect as vitestExpect } from 'vitest';
import { expect, test } from './index';

// The fixture's runtime behavior (seed = title, CI = instant) is exercised by
// Playwright Test itself, not vitest — these checks confirm the module wires up
// and re-exports a usable `test` (with the `human` fixture extended on) and
// `expect`, which is what would break if the subpath build or imports regress.
describe('@humanjs/playwright/test', () => {
it('re-exports a Playwright test extended with fixtures', () => {
vitestExpect(typeof test).toBe('function');
vitestExpect(typeof test.extend).toBe('function');
vitestExpect(typeof test.use).toBe('function');
});

it('re-exports expect', () => {
vitestExpect(typeof expect).toBe('function');
});
});
72 changes: 72 additions & 0 deletions packages/playwright/src/test/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Playwright Test integration — opt-in via the `@humanjs/playwright/test`
* subpath. Re-exports `test` / `expect` from `@playwright/test` with a
* pre-wired `human` fixture, so a spec never hand-rolls `createHuman`.
*
* Each test gets a {@link Human} bound to its `page`, **seeded from the test
* title** (deterministic per test, so snapshots stay stable) and **instant in
* CI / humanized locally** (fast pipelines, real-pace runs at your desk).
* Override any of that per-file or per-project with the `humanOptions` option.
*
* `@playwright/test` is an optional peer — it's how you run Playwright tests,
* so the host already provides it. Keeping the fixture on a subpath means the
* package root never pulls in the test runner.
*
* @example
* ```ts
* import { test, expect } from '@humanjs/playwright/test';
*
* test('checkout', async ({ human, page }) => {
* await human.goto('/cart');
* await human.click('Checkout');
* await expect(page).toHaveURL(/success/);
* });
*
* // Customize for a file (or a whole project via playwright.config.ts):
* test.use({ humanOptions: { personality: 'distracted', speed: 'human' } });
* ```
*/

import { test as base } from '@playwright/test';
import { type CreateHumanOptions, createHuman, type Human } from '../index';

/** Test-scoped fixtures added by `@humanjs/playwright/test`. */
export interface HumanFixtures {
/**
* A {@link Human} bound to the test's `page`. Created fresh per test; by
* default seeded from the test title and run instant in CI, humanized
* locally. Tune via the `humanOptions` option.
*/
human: Human;
}

/** Option fixture to customize the per-test {@link Human}. */
export interface HumanOptions {
/**
* Options forwarded to `createHuman` for the `human` fixture. Set with
* `test.use({ humanOptions: { … } })` (per file) or in `playwright.config.ts`
* `use` (per project). Defaults: `seed` = the test title, `speed` =
* `'instant'` in CI else `'human'`. Anything you set here overrides those.
*/
humanOptions: CreateHumanOptions;
}

/**
* Playwright `test` extended with the `human` fixture. Drop-in replacement
* for `@playwright/test`'s `test`.
*/
export const test = base.extend<HumanFixtures & HumanOptions>({
humanOptions: [{}, { option: true }],
human: async ({ page, humanOptions }, use, testInfo) => {
const human = await createHuman(page, {
// Deterministic per test, CI-fast by default — both overridable since
// the spread comes last.
seed: testInfo.title,
speed: process.env.CI ? 'instant' : 'human',
...humanOptions,
});
await use(human);
},
});

export { expect } from '@playwright/test';
9 changes: 7 additions & 2 deletions packages/playwright/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { defineConfig } from 'tsup';

export default defineConfig({
entry: ['src/index.ts'],
// Object form pins predictable output names: dist/index.* and dist/test.*
// (the `@humanjs/playwright/test` Playwright-fixture subpath).
entry: { index: 'src/index.ts', test: 'src/test/index.ts' },
format: ['esm', 'cjs'],
dts: true,
clean: true,
sourcemap: true,
treeshake: true,
splitting: false,
// Code splitting extracts the shared library code (createHuman + deps) into a
// chunk both entries import, instead of inlining a full copy into the `/test`
// entry. Dedupes both ESM and CJS output — keeps the package tarball lean.
splitting: true,
});
Loading