E2E: Add a browse command to drive the local site from the command line by ellatrix · Pull Request #85525 · WordPress/gutenberg · GitHub
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
53 changes: 53 additions & 0 deletions .agents/skills/browse/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
name: browse
description: Use when checking a change by hand in the real editor or admin, from the command line: open a logged-in browser on the local site, set up content, use the keyboard, call the REST API, and read back the blocks or the page.
---

# Browse

`npm run browse` drives one browser on this checkout's WordPress site. The browser stays open between commands, so each command continues where the last one stopped. It uses `WP_BASE_URL`, like the e2e tests, and defaults to `http://localhost:8889`.

Always pass the command after `--`, so npm does not take its flags. Run `npm run browse -- open` first: it opens the browser on the dashboard, logged in as admin.

## Commands

Each one calls an e2e test util from `packages/e2e-test-utils-playwright/src`, which shows the options it takes. Arguments are read as JSON when they are JSON, otherwise as text.

| Command | Does | Test util |
| --- | --- | --- |
| `new-post [options]` | Opens a new post, e.g. `new-post '{"postType":"page","content":"<block markup>"}'`. | `admin.createNewPost`, `editor.setContent` |
| `edit-post <id>` | Opens a post in the editor. | `admin.editPost` |
| `site-editor [options]` | Opens the site editor. | `admin.visitSiteEditor` |
| `content [markup]` | Prints the post's block markup, or sets it. | `editor.getEditedPostContent`, `editor.setContent` |
| `blocks [blocks]` | Prints the blocks with all their attributes, as JSON, or sets them from the same shape, e.g. `blocks '[{"name":"core/heading","attributes":{"content":"Hi"}}]'`. | `editor.getBlocks`, `editor.insertBlock` |
| `insert <block>` | Inserts a block, in the shape `blocks` prints, e.g. `insert '{"name":"core/heading","attributes":{"level":3}}'`. | `editor.insertBlock` |
| `transform <name>` | Transforms the selected block, e.g. `transform core/paragraph`. | `editor.transformBlockTo` |
| `keys <keys>` | Presses keys, with `primary` for Cmd or Ctrl, e.g. `keys primary+z`. | `pageUtils.pressKeys` |
| `focus` | Prints what has keyboard focus. | `editor.getFocusOwnerLabel` |
| `save`, `publish` | Saves a draft, or publishes. | `editor.saveDraft`, `editor.publishPost` |
| `api <method> <path> [data]` | Calls the REST API as admin, like curl, e.g. `api POST /wp/v2/posts '{"title":"Hi"}'`. Needs no browser. | `requestUtils.rest` |

## Everything else: playwright-cli

Any other command goes to [playwright-cli](https://playwright.dev/docs/getting-started-cli) in the same browser: `snapshot` (the page as text, with refs), `click <ref>`, `type <text>`, `press <key>`, `goto <full URL>`, `screenshot`, `close`. Run `npm run browse -- --help` for the full list; its first lines point to playwright-cli's own skill.

To record a video of a check, wrap the steps in `video-start` and `video-stop`, which prints the file's path. It records everything on the page, including the commands above. `video-chapter <title>` marks a step, and `video-show-actions` labels each action on screen.

## Check a change

1. Run `open`, then set the page up, for example with `new-post`.
2. Check the setup with `blocks` or `snapshot` before acting.
3. Act with the keyboard where a user would. Click a block before typing into it.
4. Check the result exactly with `blocks`, including attributes, and `snapshot` or `screenshot` for what is on screen.

```sh
npm run browse -- open
npm run browse -- new-post '{"content":"<!-- wp:paragraph --><p>Hi</p><!-- /wp:paragraph -->"}'
npm run browse -- snapshot
npm run browse -- click <ref of the paragraph>
npm run browse -- press End
npm run browse -- press Enter
npm run browse -- blocks
```

A check by hand does not replace an e2e test. Once the steps work, write them as one.
47 changes: 47 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
},
"scripts": {
"agents:setup": "npm run --workspace @wordpress/agent-tools setup --",
"browse": "npm run browse --workspace @wordpress/e2e-tests-playwright --",
"build": "npm run --workspace @wordpress/build-scripts build:all --",
"build:plugin-zip": "npm run --workspace @wordpress/release-tools build-plugin-zip --",
"check:type-declarations": "npm run --workspace @wordpress/build-scripts check-type-declarations --",
Expand Down
178 changes: 178 additions & 0 deletions test/e2e/bin/browse.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
#!/usr/bin/env node

/**
* Drives a browser on this checkout's WordPress site, to check a change by
* hand. The commands below call the e2e test utils, with each argument read
* as JSON when it is JSON; any other command goes to playwright-cli, in the
* same browser.
*
* npm run browse -- open
* npm run browse -- new-post
* npm run browse -- insert '{"name":"core/heading"}'
* npm run browse -- press Enter
* npm run browse -- blocks
*/
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from '@playwright/test';
import {
Admin,
Editor,
PageUtils,
RequestUtils,
} from '@wordpress/e2e-test-utils-playwright';

const workDir = fileURLToPath(
new URL( '../artifacts/browse', import.meta.url )
);
const profileDir = path.join( workDir, 'profile' );
// One browser per checkout, so parallel worktrees never share one.
const session = `gutenberg-${ createHash( 'sha1' )
.update( workDir )
.digest( 'hex' )
.slice( 0, 8 ) }`;

function playwrightCli( args ) {
return spawnSync( 'playwright-cli', [ `-s=${ session }`, ...args ], {
stdio: 'inherit',
} ).status;
}

// Connects to the browser playwright-cli keeps open, through the port Chrome
// picked itself and wrote to its profile.
async function withHelpers( callback ) {
let browser;
try {
const port = readFileSync(
path.join( profileDir, 'DevToolsActivePort' ),
'utf8'
).split( '\n' )[ 0 ];
browser = await chromium.connectOverCDP( `http://127.0.0.1:${ port }` );
} catch {
throw new Error( 'No browser open. Run `npm run browse -- open`.' );
}
const page = browser.contexts()[ 0 ].pages()[ 0 ];
// A browser we connect to has no base URL, so resolve paths like
// `wp-admin/post-new.php` against the site, as the test fixture does.
const baseURL = process.env.WP_BASE_URL || 'http://localhost:8889';
const goto = page.goto.bind( page );
page.goto = ( url, options ) =>
goto( new URL( url, baseURL ).href, options );
try {
const pageUtils = new PageUtils( { page, browserName: 'chromium' } );
const editor = new Editor( { page } );
return await callback( {
page,
pageUtils,
editor,
admin: new Admin( { page, pageUtils, editor } ),
} );
} finally {
// Only disconnects: the browser stays open for the next command.
await browser.close();
}
}

async function open() {
mkdirSync( workDir, { recursive: true } );
const config = path.join( workDir, 'cli.config.json' );
writeFileSync(
config,
JSON.stringify( {
outputDir: workDir,
browser: {
userDataDir: profileDir,
launchOptions: { args: [ '--remote-debugging-port=0' ] },
},
} )
);
if ( playwrightCli( [ 'open', `--config=${ config }` ] ) !== 0 ) {
throw new Error( 'playwright-cli could not open the browser.' );
}
const { cookies } = await ( await RequestUtils.setup( {} ) ).setupRest();
await withHelpers( async ( { page, admin } ) => {
await page.context().addCookies( cookies );
await admin.visitAdminPage( '' );
} );
}

// The e2e test utils worth calling by hand, by command name.
const commands = {
// WordPress escapes block markup passed to createNewPost as `content`.
'new-post': async ( { admin, editor }, { content, ...options } = {} ) => {
await admin.createNewPost( options );
if ( content ) {
await editor.setContent( content );
}
},
'edit-post': ( { admin }, postId ) => admin.editPost( postId ),
'site-editor': ( { admin }, options ) => admin.visitSiteEditor( options ),
content: ( { editor }, markup ) =>
markup === undefined
? editor.getEditedPostContent()
: editor.setContent( markup ),
blocks: async ( { editor }, blocks ) => {
if ( blocks === undefined ) {
return editor.getBlocks();
}
await editor.setContent( '' );
// Each block goes in after the one inserted before it.
for ( const block of blocks ) {
await editor.insertBlock( block );
}
},
insert: ( { editor }, block ) => editor.insertBlock( block ),
transform: ( { editor }, name ) => editor.transformBlockTo( name ),
keys: ( { pageUtils }, keys ) => pageUtils.pressKeys( keys ),
focus: ( { editor } ) => editor.getFocusOwnerLabel(),
save: ( { editor } ) => editor.saveDraft(),
publish: ( { editor } ) => editor.publishPost(),
};

// Like curl for the site's REST API, logged in as admin; needs no browser.
async function api( method, restPath, data ) {
const requestUtils = await RequestUtils.setup( {} );
return requestUtils.rest( { method, path: restPath, data } );
}

async function run( command, args ) {
if ( command === 'open' ) {
return open();
}
if ( command === 'api' ) {
return api( ...args );
}
return withHelpers( ( helpers ) =>
commands[ command ]( helpers, ...args )
);
}

const [ command, ...args ] = process.argv.slice( 2 );
if ( [ 'open', 'api', ...Object.keys( commands ) ].includes( command ) ) {
run(
command,
args.map( ( arg ) => {
try {
return JSON.parse( arg );
} catch {
return arg;
}
} )
)
.then( ( result ) => {
if ( typeof result === 'string' ) {
console.log( result );
} else if ( result !== undefined ) {
console.log( JSON.stringify( result, null, '\t' ) );
}
} )
.catch( ( error ) => {
console.error( error.message );
process.exitCode = 1;
} );
} else {
process.exitCode = playwrightCli( process.argv.slice( 2 ) );
}
2 changes: 2 additions & 0 deletions test/e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
},
"devDependencies": {
"@flakiness/playwright": "^2.0.1",
"@playwright/cli": "^0.1.22",
"@playwright/test": "^1.63.0",
"@types/node": "^24.13.3",
"@wordpress/e2e-test-utils-playwright": "file:../../packages/e2e-test-utils-playwright",
Expand All @@ -39,6 +40,7 @@
"access": "public"
},
"scripts": {
"browse": "node bin/browse.mjs",
"test:e2e:update-timings": "flakiness-playwright-timings fetch -c ./playwright.config.ts -o timings.json",
"test:e2e": "wp-scripts test-e2e --config playwright.config.ts --wp-env-config=../../.wp-env.test.json",
"test:e2e:debug": "wp-scripts test-e2e --config playwright.config.ts --wp-env-config=../../.wp-env.test.json --ui",
Expand Down
52 changes: 52 additions & 0 deletions test/e2e/specs/editor/various/browse-command.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { execFileSync } from 'child_process';
import { test, expect } from '@wordpress/e2e-test-utils-playwright';

// Runs `npm run browse` the way an agent does, on the site under test.
function browse( ...args: string[] ) {
return execFileSync(
'npm',
[ 'run', '--silent', 'browse', '--', ...args ],
{
encoding: 'utf8',
}
).trim();
}

test.describe( 'npm run browse', () => {
test.afterAll( () => {
browse( 'close' );
} );

test( 'opens a post, edits its blocks and calls the REST API', () => {
browse( 'open' );
browse(
'new-post',
JSON.stringify( {
content: '<!-- wp:paragraph --><p>Hi</p><!-- /wp:paragraph -->',
} )
);
browse(
'insert',
JSON.stringify( {
name: 'core/heading',
attributes: { content: 'Title', level: 3 },
} )
);

expect( JSON.parse( browse( 'blocks' ) ) ).toEqual( [
{
name: 'core/paragraph',
attributes: { content: 'Hi', dropCap: false },
innerBlocks: [],
},
{
name: 'core/heading',
attributes: { content: 'Title', level: 3 },
innerBlocks: [],
},
] );
expect(
JSON.parse( browse( 'api', 'GET', '/wp/v2/types/post' ) ).slug
).toBe( 'post' );
} );
} );
Loading