From 7fdd0c1b0342e3c2f80ad691a74e0608a3c69474 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Sat, 24 Oct 2026 02:57:04 +0800 Subject: [PATCH 1/7] test(e2e): add intake demo recording scripts Playwright config, helpers and per-intake walkthrough scripts that record one captioned demo video per intake. Output goes to the gitignored tests/e2e/test-results/demo/ folder. --- tests/e2e/demo/c37d0fc8.demo.js | 336 +++++++++++++++++++++++ tests/e2e/demo/check.js | 164 +++++++++++ tests/e2e/demo/e492c759.demo.js | 296 ++++++++++++++++++++ tests/e2e/demo/kit.js | 98 +++++++ tests/e2e/demo/playwright.demo.config.js | 44 +++ 5 files changed, 938 insertions(+) create mode 100644 tests/e2e/demo/c37d0fc8.demo.js create mode 100644 tests/e2e/demo/check.js create mode 100644 tests/e2e/demo/e492c759.demo.js create mode 100644 tests/e2e/demo/kit.js create mode 100644 tests/e2e/demo/playwright.demo.config.js diff --git a/tests/e2e/demo/c37d0fc8.demo.js b/tests/e2e/demo/c37d0fc8.demo.js new file mode 100644 index 0000000..f16e4f2 --- /dev/null +++ b/tests/e2e/demo/c37d0fc8.demo.js @@ -0,0 +1,336 @@ +// @demo-acs AC-001 AC-002 AC-003 AC-005 AC-008 AC-009 AC-013 AC-018 AC-019 AC-020 AC-023 +/** + * One-take demo for intake c37d0fc8: send test emails through the beehiiv + * test-send endpoint (PRD-06.5.01). Built for the build the intake handed to + * UAT (ab1f909). + * + * Run through the record-intake-demo skill (config: playwright.demo.config.js). + * State is arranged with the tests-environment seams the QA spec uses + * (tests/e2e/plugins/beehiiv-options.php). Every action in the video goes + * through the real editor UI; wp-cli only seeds data and reads back which + * calls the plugin made to beehiiv. + */ +const path = require( 'path' ); +const os = require( 'os' ); +const fs = require( 'fs' ); +const kit = require( './kit' ); + +const { test, expect, wp, installOverlay, caption, glideClick, point } = kit; + +const PUB = 'pub_e2e'; +const TPL = 'tpl_e2e'; +const TMP_ID = 'post_tmp_e2e'; +const SITE_TZ = 'America/New_York'; +const STATE = path.join( os.tmpdir(), 'demo-c37d0fc8-auth.json' ); +const ids = []; + +const b64 = ( v ) => Buffer.from( v ).toString( 'base64' ); +const arg = ( v ) => + `json_decode( base64_decode( "${ b64( JSON.stringify( v ) ) }" ), true )`; +const php = ( code ) => + wp.wpCli( `eval 'eval( base64_decode( "${ b64( code ) }" ) );'` ); + +/** + * Seeds a connected, ready site and an empty request log. + */ +function seed() { + php( ` + beehiiv_e2e_reset_all(); + beehiiv_e2e_seed_connection(); + beehiiv_e2e_mock_permissions( ${ arg( { posts: [ 'read', 'write' ] } ) } ); + remove_all_filters( "sanitize_option_beehiiv_settings" ); + update_option( "beehiiv_settings", ${ arg( { + publication_id: PUB, + post_template_id: TPL, + } ) } ); + delete_option( "beehiiv_test_send_reset_at" ); + update_option( "timezone_string", "${ SITE_TZ }" ); + ` ); +} + +/** + * Registers beehiiv post-endpoint mocks for create, test send, delete and get. + * + * @param {Object} testSend Response for POST .../test_sends. + */ +function mockBeehiiv( testSend ) { + const mocks = { + test_sends: { + pattern: '#/publications/[^/]+/posts/[^/?]+/test_sends(\\?|$)#', + method: 'POST', + ...testSend, + }, + create_post: { + pattern: '#/publications/[^/]+/posts(\\?|$)#', + method: 'POST', + status: 201, + body: { data: { id: TMP_ID } }, + }, + delete_post: { + pattern: '#/publications/[^/]+/posts/[^/?]+(\\?|$)#', + method: 'DELETE', + status: 204, + body: '', + }, + get_post: { + pattern: '#/publications/[^/]+/posts/[^/?]+(\\?|$)#', + method: 'GET', + status: 200, + body: { data: { id: TMP_ID } }, + }, + }; + php( ` + foreach ( ${ arg( + mocks + ) } as $key => $mock ) { beehiiv_e2e_mock_http( $key, $mock ); } + beehiiv_e2e_clear_http_log(); + ` ); +} + +/** + * Creates a post straight in the database and returns its ID. + * + * @param {string} title Post title. + * @param {string} status Post status. + * @return {number} The new post ID. + */ +function createPost( title, status = 'draft' ) { + const content = + '

Our October update: three things worth knowing.

'; + const out = php( ` + $id = wp_insert_post( ${ arg( { + post_type: 'post', + post_title: title, + post_content: content, + post_status: status, + } ) } ); + echo "ID=" . $id; + ` ); + const id = Number( /ID=(\d+)/.exec( out )[ 1 ] ); + ids.push( id ); + return id; +} + +/** + * Mocked beehiiv calls the plugin made, as "METHOD name" in order, with bodies. + * + * @return {Array} Log entries. + */ +function httpLog() { + const out = php( + 'echo "JSON=" . wp_json_encode( array_values( (array) get_option( "beehiiv_e2e_http_log", [] ) ) );' + ); + return JSON.parse( out.slice( out.indexOf( 'JSON=' ) + 5 ) ).filter( + ( e ) => e.mock !== '/workspaces/permissions' + ); +} + +const section = ( page ) => page.locator( '.beehiiv-test-email' ); +const recipients = ( page ) => page.getByLabel( 'Send test email to' ); +const sendButton = ( page ) => + page.getByRole( 'button', { name: 'Send test email', exact: true } ); + +/** + * Opens a post in the editor with the beehiiv panel visible. + * + * @param {import('@playwright/test').Page} page Page. + * @param {number} postId Post ID. + */ +async function openPanel( page, postId ) { + await kit.openPostEditor( page, postId ); + const toggle = page + .getByRole( 'button', { name: 'beehiiv', exact: true } ) + .first(); + await toggle.waitFor( { state: 'visible', timeout: 15000 } ); + const open = + ( await toggle.getAttribute( 'aria-expanded' ) ) === 'true' || + ( await toggle.getAttribute( 'aria-pressed' ) ) === 'true'; + if ( ! open ) { + await toggle.click(); + } + await expect( + page.locator( '.beehiiv-post-settings-content' ) + ).toBeVisible(); +} + +test.use( { storageState: STATE, viewport: { width: 1280, height: 720 } } ); + +test.beforeAll( async ( { browser } ) => { + wp.ensurePluginActive(); + wp.ensurePrettyPermalinks(); + seed(); + // Log in before recording starts, so the video opens on the product. + const ctx = await browser.newContext( { + storageState: undefined, + baseURL: process.env.WP_BASE_URL || 'http://localhost:8891', + } ); + const page = await ctx.newPage(); + await kit.loginAsAdmin( page ); + await ctx.storageState( { path: STATE } ); + await ctx.close(); +} ); + +test.afterAll( () => { + php( ` + foreach ( array_filter( explode( ",", "${ ids.join( + ',' + ) }" ) ) as $id ) { wp_delete_post( (int) $id, true ); } + beehiiv_e2e_reset_all(); + delete_option( "beehiiv_settings" ); + delete_option( "beehiiv_test_send_reset_at" ); + update_option( "timezone_string", "" ); + ` ); + fs.rmSync( STATE, { force: true } ); +} ); + +test( 'Send test emails from the post editor: full walkthrough', async ( { + page, +} ) => { + await installOverlay( page ); + const resetAt = Math.floor( Date.now() / 1000 ) + 6 * 3600 + 17 * 60; + mockBeehiiv( { + status: 200, + body: { data: { remaining_test_sends: 7, reset_at: resetAt } }, + } ); + + // 1. A draft: send a test email --------------------------------------------- + const draftId = createPost( 'October update' ); + await openPanel( page, draftId ); + await caption( + page, + 'Send test emails through beehiiv · plugin build ab1f909', + 3500 + ); + await caption( + page, + '1/4 · A draft post. The beehiiv panel has a "Send test email" section (AC-001)', + 1200 + ); + await point( page, section( page ), 2500 ); + await expect( recipients( page ) ).toHaveValue( '' ); + await caption( + page, + 'The recipients field starts empty. There is no default address (AC-002)', + 1200 + ); + await point( page, recipients( page ), 3000 ); + + await caption( + page, + 'Type addresses separated by commas or new lines, with extra spaces and a duplicate (AC-003)', + 1200 + ); + await glideClick( page, recipients( page ) ); + await recipients( page ).fill( + ' editor@example.com , reviewer@example.com\n\nowner@example.com,editor@example.com ' + ); + await page.waitForTimeout( 3500 ); + await glideClick( page, sendButton( page ) ); + await expect( section( page ) ).toContainText( 'Test email sent.', { + timeout: 20000, + } ); + + const calls = httpLog(); + expect( calls.map( ( c ) => `${ c.method } ${ c.mock }` ) ).toEqual( [ + 'POST create_post', + 'GET get_post', + 'POST test_sends', + 'DELETE delete_post', + ] ); + expect( calls[ 0 ].body.status ).toBe( 'draft' ); + expect( calls[ 0 ].body ).not.toHaveProperty( 'scheduled_at' ); + expect( calls[ 2 ].body.recipient_emails ).toEqual( [ + 'editor@example.com', + 'reviewer@example.com', + 'owner@example.com', + ] ); + await caption( + page, + 'beehiiv gets 3 addresses: spaces trimmed, the duplicate removed (AC-003)', + 4000 + ); + await caption( + page, + 'A draft has no beehiiv newsletter yet. The plugin creates a temporary beehiiv draft, sends the test, then deletes it (AC-008, AC-009)', + 5500 + ); + await point( page, section( page ), 800 ); + await expect( section( page ) ).toContainText( '7 test sends left today.' ); + await caption( + page, + 'The editor sees how many test sends are left today and when the limit resets, in the site timezone (AC-018)', + 5500 + ); + + // 2. Unsaved edits ---------------------------------------------------------- + await caption( + page, + '2/4 · The test uses the saved post. Edit the title without saving…', + 1200 + ); + const title = page + .frameLocator( 'iframe[name="editor-canvas"]' ) + .locator( '.editor-post-title__input, [aria-label="Add title"]' ) + .first(); + await glideClick( page, title ); + await page.keyboard.press( 'End' ); + await page.keyboard.type( ' (draft 2)', { delay: 90 } ); + await expect( section( page ) ).toContainText( + 'Save the post to include your latest edits.' + ); + await expect( sendButton( page ) ).toBeDisabled(); + await caption( + page, + 'Send is disabled until the post is saved. The plugin never saves for the editor (AC-005)', + 5000 + ); + await page.evaluate( async () => { + await window.wp.data.dispatch( 'core/editor' ).savePost(); + } ); + await expect( sendButton( page ) ).toBeEnabled( { timeout: 15000 } ); + await caption( page, 'After saving, Send is available again', 2500 ); + + // 3. Daily limit reached ------------------------------------------------------ + mockBeehiiv( { + status: 422, + body: { errors: [ { message: 'Daily limit' } ] }, + } ); + await caption( page, '3/4 · When the daily limit is reached…', 1200 ); + const typed = 'editor@example.com, reviewer@example.com'; + await glideClick( page, recipients( page ) ); + await recipients( page ).fill( typed ); + await glideClick( page, sendButton( page ) ); + await expect( section( page ) ).toContainText( + "You've used all test sends for today.", + { timeout: 20000 } + ); + await expect( section( page ) ).toContainText( 'They reset on' ); + await caption( + page, + 'The editor sees that all sends are used, with the reset time remembered from the last send (AC-019, AC-020)', + 5500 + ); + await expect( recipients( page ) ).toHaveValue( typed ); + await point( page, recipients( page ), 800 ); + await caption( + page, + 'The typed addresses stay in the field so the editor can try again (AC-023)', + 4500 + ); + + // 4. Published posts cannot send tests ---------------------------------------- + const publishedId = createPost( 'Already published post', 'publish' ); + await caption( page, '4/4 · A published post', 0 ); + await openPanel( page, publishedId ); + await expect( section( page ) ).toContainText( + 'Test emails are available for drafts, pending posts, and scheduled posts.' + ); + await expect( sendButton( page ) ).toHaveCount( 0 ); + await point( page, section( page ), 800 ); + await caption( + page, + 'No send control. A short reason is shown instead (AC-013)', + 5000 + ); + await caption( page, 'End of walkthrough', 2500 ); +} ); diff --git a/tests/e2e/demo/check.js b/tests/e2e/demo/check.js new file mode 100644 index 0000000..8d0ee2c --- /dev/null +++ b/tests/e2e/demo/check.js @@ -0,0 +1,164 @@ +#!/usr/bin/env node +/** + * record-intake-demo helper. No dependencies. + * + * node tests/e2e/demo/check.js check + * node tests/e2e/demo/check.js finish + * + * The demo script declares the ACs it shows on its first line: + * // @demo-acs AC-001 AC-007 ... + * `check` proves the demo is about this intake: every declared AC must exist + * in the intake's routed PRDs, and the plugin checkout's commit must be one + * the intake recorded. ACs the demo does not show are reported, not failed. + * `finish` runs after Playwright: it copies the single recorded video to + * /walkthrough.webm and writes manifest.json and manifest.md. + */ +const fs = require( 'fs' ); +const path = require( 'path' ); +const { execSync } = require( 'child_process' ); + +const [ , , cmd, intakePath, demoScriptArg, outDir ] = process.argv; +const pluginDir = path.resolve( __dirname, '../../..' ); +const demoScript = path.resolve( __dirname, demoScriptArg ); +const relayRoot = path.resolve( intakePath, '../../../..' ); + +const read = ( p ) => fs.readFileSync( p, 'utf8' ); +const uniq = ( a ) => [ ...new Set( a ) ].sort(); + +function routedTargets( intake ) { + const block = intake.match( + /([\s\S]*?)/ + ); + if ( ! block ) { + throw new Error( 'No route-intake:targets block in the intake.' ); + } + return [ ...block[ 1 ].matchAll( /^\s*-\s*path:\s*(\S+)/gm ) ].map( + ( m ) => m[ 1 ] + ); +} + +function findPrd( target ) { + const slug = target.split( '/' ).pop(); + const hits = execSync( + `find "${ path.join( + relayRoot, + 'requirements' + ) }" -name "${ slug }.prd.md"` + ) + .toString() + .trim() + .split( '\n' ) + .filter( Boolean ); + if ( hits.length !== 1 ) { + throw new Error( + `Expected one PRD for ${ target }, found ${ hits.length }.` + ); + } + return hits[ 0 ]; +} + +function prdAcs( file ) { + return uniq( + [ ...read( file ).matchAll( /^- \[[ x]\] (AC-\d+):/gm ) ].map( + ( m ) => m[ 1 ] + ) + ); +} + +function demoAcs( file ) { + const line = read( file ).match( /^\/\/ @demo-acs (.+)$/m ); + if ( ! line ) { + throw new Error( + 'The demo script has no "// @demo-acs AC-001 ..." line.' + ); + } + return uniq( line[ 1 ].match( /AC-\d+/g ) || [] ); +} + +function recordedShas( intake ) { + return uniq( + [ ...intake.matchAll( /\b[0-9a-f]{7}\b/g ) ].map( ( m ) => m[ 0 ] ) + ); +} + +const intake = read( intakePath ); +const targets = routedTargets( intake ); +const prds = targets.map( ( t ) => ( { target: t, file: findPrd( t ) } ) ); +const wanted = uniq( prds.flatMap( ( p ) => prdAcs( p.file ) ) ); +const shown = demoAcs( demoScript ); + +function summary() { + const head = execSync( 'git rev-parse HEAD', { cwd: pluginDir } ) + .toString() + .trim(); + const branch = execSync( 'git rev-parse --abbrev-ref HEAD', { + cwd: pluginDir, + } ) + .toString() + .trim(); + const result = { + intake: path.basename( intakePath ), + targets, + head, + branch, + build_matches_intake: recordedShas( intake ).some( ( s ) => + head.startsWith( s ) + ), + acs_shown: shown, + acs_not_shown: wanted.filter( ( a ) => ! shown.includes( a ) ), + acs_not_in_intake: shown.filter( ( a ) => ! wanted.includes( a ) ), + }; + result.ok = + result.build_matches_intake && ! result.acs_not_in_intake.length; + return result; +} + +function check() { + const result = summary(); + process.stdout.write( JSON.stringify( result, null, 2 ) + '\n' ); + process.exit( result.ok ? 0 : 1 ); +} + +function finish() { + const result = summary(); + const results = JSON.parse( read( path.join( outDir, 'results.json' ) ) ); + const spec = results.suites[ 0 ].specs[ 0 ]; + const run = spec.tests[ 0 ].results.slice( -1 )[ 0 ]; + const vid = ( run.attachments || [] ).find( ( a ) => a.name === 'video' ); + if ( ! vid || ! fs.existsSync( vid.path ) ) { + throw new Error( 'Playwright recorded no video.' ); + } + fs.copyFileSync( vid.path, path.join( outDir, 'walkthrough.webm' ) ); + const out = { + ...result, + commit: result.head.slice( 0, 7 ), + test_status: run.status, + seconds: Math.round( run.duration / 1000 ), + video: 'walkthrough.webm', + }; + fs.writeFileSync( + path.join( outDir, 'manifest.json' ), + JSON.stringify( out, null, 2 ) + ); + const md = [ + `# Demo walkthrough: ${ out.intake } @ ${ out.commit }`, + '', + `Video: ${ out.video } (${ out.seconds }s, test ${ out.test_status })`, + `Criteria shown: ${ out.acs_shown.join( ', ' ) }`, + `Criteria in the PRDs but not shown: ${ + out.acs_not_shown.join( ', ' ) || 'none' + }`, + `Shown but not in the routed PRDs: ${ + out.acs_not_in_intake.join( ', ' ) || 'none' + }`, + ].join( '\n' ); + fs.writeFileSync( path.join( outDir, 'manifest.md' ), md + '\n' ); + process.stdout.write( md + '\n' ); +} + +( + ( { check, finish } )[ cmd ] || + ( () => { + throw new Error( 'Use check or finish.' ); + } ) +)(); diff --git a/tests/e2e/demo/e492c759.demo.js b/tests/e2e/demo/e492c759.demo.js new file mode 100644 index 0000000..7cd9586 --- /dev/null +++ b/tests/e2e/demo/e492c759.demo.js @@ -0,0 +1,296 @@ +// @demo-acs AC-001 AC-007 AC-009 AC-010 AC-011 AC-012 AC-016 AC-019 AC-020 AC-021 AC-022 +/** + * One-take demo for intake e492c759: Publication selector in the post sidebar + * (PRD-06.8.02). Built for the commit the intake recorded (e01e8fb). + * + * Run through the record-intake-demo skill (config: playwright.demo.config.js). State is arranged with the same + * tests-environment seams the QA spec uses (tests/e2e/plugins/beehiiv-options.php). + * Every action in the video goes through the real UI; wp-cli only seeds data + * and reads back which beehiiv publication each API call targeted. + */ +const path = require( 'path' ); +const os = require( 'os' ); +const fs = require( 'fs' ); +const kit = require( './kit' ); +const { test, expect, wp, installOverlay, caption, glideClick, point } = kit; + +const PUB_A = { id: 'pub_qa_alpha', name: 'Alpha Daily' }; +const PUB_B = { id: 'pub_qa_beta', name: 'Beta Weekly' }; +const PUB_C = { id: 'pub_qa_gamma', name: 'Gamma Monthly' }; +const TEMPLATES = { + [ PUB_A.id ]: [ + { id: 'tpl_a1', name: 'Alpha Standard' }, + { id: 'tpl_a2', name: 'Alpha Promo' }, + ], + [ PUB_B.id ]: [ + { id: 'tpl_b1', name: 'Beta Classic' }, + { id: 'tpl_b2', name: 'Beta Digest' }, + ], +}; +const CREATED = { [ PUB_A.id ]: 'bh_alpha_new', [ PUB_B.id ]: 'bh_beta_new' }; +const PUB_SELECT = '.beehiiv-newsletter-publication select'; +const TPL_SELECT = '.beehiiv-newsletter-template select'; +const STATE = path.join( os.tmpdir(), 'demo-e492c759-auth.json' ); +const ids = []; + +const b64 = ( v ) => Buffer.from( JSON.stringify( v ) ).toString( 'base64' ); +const J = ( v ) => `json_decode( base64_decode( "${ b64( v ) }" ), true )`; +const php = ( code ) => + wp.wpCli( + `eval 'eval( base64_decode( "${ Buffer.from( code ).toString( + 'base64' + ) }" ) );'` + ); + +function seed() { + let code = ` + beehiiv_e2e_reset_all(); + beehiiv_e2e_seed_connection(); + beehiiv_e2e_mock_permissions( [ "posts" => [ "read", "write" ] ] ); + beehiiv_e2e_seed_publications( ${ J( [ PUB_A, PUB_B ] ) } ); + update_option( "beehiiv_settings", ${ J( { + publication_id: PUB_A.id, + post_template_id: 'tpl_a1', + } ) } ); + beehiiv_e2e_mock_http( "/publications?", [ "body" => [ "data" => ${ J( [ + PUB_A, + PUB_B, + PUB_C, + ] ) } ] ] ); + `; + for ( const [ pub, tpls ] of Object.entries( TEMPLATES ) ) { + code += `beehiiv_e2e_seed_post_templates( "${ pub }", ${ J( + tpls + ) } );`; + code += `beehiiv_e2e_mock_http( "/publications/${ pub }/posts", [ "body" => [ "data" => [ "id" => "${ CREATED[ pub ] }" ] ] ] );`; + } + php( code + 'beehiiv_e2e_start_http_log();' ); +} + +function createPost( title, meta = {} ) { + const out = php( ` + $id = wp_insert_post( wp_slash( ${ J( { + post_type: 'post', + post_title: title, + post_status: 'draft', + post_content: + '

This week in newsletters.

', + } ) } ) ); + foreach ( ${ J( { + _beehiiv_send_to_newsletter: '1', + ...meta, + } ) } as $k => $v ) { update_post_meta( $id, $k, $v ); } + beehiiv_e2e_start_http_log(); + echo "ID=" . $id; + ` ); + const id = Number( /ID=(\d+)/.exec( out )[ 1 ] ); + ids.push( id ); + return id; +} + +/** Which beehiiv publications received a create-post call since the log started. */ +function createdIn() { + const out = php( + 'echo "JSON=" . wp_json_encode( beehiiv_e2e_get_http_log() );' + ); + return JSON.parse( out.slice( out.indexOf( 'JSON=' ) + 5 ) ) + .filter( + ( e ) => + e.method === 'POST' && + /\/publications\/[^/]+\/posts/.test( e.url ) + ) + .map( ( e ) => /\/publications\/([^/]+)\/posts/.exec( e.url )[ 1 ] ); +} + +async function openEditor( page, id ) { + await kit.openPostEditor( page, id ); + const content = page.locator( '.beehiiv-post-settings-content' ); + if ( ! ( await content.isVisible().catch( () => false ) ) ) { + await page + .getByRole( 'button', { name: 'beehiiv', exact: true } ) + .first() + .click(); + } + await expect( content ).toBeVisible(); + await expect( page.locator( TPL_SELECT ) ).toBeVisible( { + timeout: 15000, + } ); +} + +async function publishPost( page ) { + await page.evaluate( async () => { + const { dispatch } = window.wp.data; + dispatch( 'core/editor' ).editPost( { status: 'publish' } ); + await dispatch( 'core/editor' ).savePost(); + } ); +} + +test.use( { storageState: STATE, viewport: { width: 1280, height: 720 } } ); + +test.beforeAll( async ( { browser } ) => { + wp.ensurePluginActive(); + wp.ensurePrettyPermalinks(); + seed(); + // Log in before recording starts, so the video opens on the product. + const ctx = await browser.newContext( { + storageState: undefined, + baseURL: process.env.WP_BASE_URL || 'http://localhost:8891', + } ); + const page = await ctx.newPage(); + await kit.loginAsAdmin( page ); + await ctx.storageState( { path: STATE } ); + await ctx.close(); +} ); + +test.afterAll( () => { + php( ` + beehiiv_e2e_reset_all(); + foreach ( array_filter( explode( ",", "${ ids.join( + ',' + ) }" ) ) as $id ) { wp_delete_post( (int) $id, true ); } + delete_option( "beehiiv_settings" ); + ` ); + fs.rmSync( STATE, { force: true } ); +} ); + +test( 'Publication selector in the post sidebar: full walkthrough', async ( { + page, +} ) => { + await installOverlay( page ); + + // 1. Settings: the site-wide default publication --------------------------- + await page.goto( '/wp-admin/admin.php?page=beehiiv' ); + const defaultSelect = page.locator( '#beehiiv_publication_id' ); + await expect( defaultSelect ).toBeVisible(); + await caption( + page, + 'Publication selector in the post sidebar · beehiiv plugin build e01e8fb', + 3500 + ); + await caption( + page, + '1/4 · Settings: the old "Publication" field is now "Default publication" (AC-020)', + 1200 + ); + await point( + page, + page.locator( 'label[for="beehiiv_publication_id"]' ), + 2500 + ); + await caption( + page, + 'It is preselected on every new post. Admins no longer switch it before each send.', + 1200 + ); + await point( page, defaultSelect, 3000 ); + await caption( + page, + 'Refresh publications reloads the list from beehiiv, and keeps the current selection (AC-021)', + 1200 + ); + await glideClick( + page, + page.getByRole( 'button', { name: 'Refresh publications' } ) + ); + await expect( + page.getByText( 'Publications updated from beehiiv.' ) + ).toBeVisible(); + await expect( + defaultSelect.locator( 'option', { hasText: PUB_C.name } ) + ).toHaveCount( 1 ); + await caption( + page, + 'A new publication, "Gamma Monthly", now appears in the list.', + 3000 + ); + + // 2. Post editor: choose the publication and template per post --------------- + const postId = createPost( 'This week in newsletters' ); + await caption( + page, + '2/4 · Post editor, beehiiv panel: each post has its own Publication selector (AC-001)', + 0 + ); + await openEditor( page, postId ); + await caption( + page, + '2/4 · The Publication selector sits directly above Post template (AC-001)', + 2500 + ); + const pub = page.locator( PUB_SELECT ); + await point( page, pub, 1500 ); + await caption( + page, + 'It starts on the default publication, "Alpha Daily", with its default template (AC-010)', + 3500 + ); + await point( page, page.locator( TPL_SELECT ), 1500 ); + await caption( + page, + "Post template lists only Alpha Daily's templates (AC-007)", + 3000 + ); + + await caption( page, 'Switch this post to "Beta Weekly"', 1000 ); + await glideClick( page, pub ); + await pub.selectOption( PUB_B.id ); + await expect( + page.locator( `${ TPL_SELECT } option`, { hasText: 'Beta Digest' } ) + ).toHaveCount( 1, { timeout: 15000 } ); + await caption( + page, + "The template is cleared and the list now shows Beta Weekly's templates. The editor must pick one (AC-009, AC-011)", + 4500 + ); + await glideClick( page, page.locator( TPL_SELECT ) ); + await page.locator( TPL_SELECT ).selectOption( 'tpl_b2' ); + await caption( page, 'Pick "Beta Digest"', 2500 ); + + // 3. Publish: the newsletter goes to the chosen publication ------------------ + await caption( page, '3/4 · Publish the post', 1500 ); + await publishPost( page ); + await expect( pub ).toBeDisabled( { timeout: 20000 } ); + const targets = createdIn(); + expect( targets ).toEqual( [ PUB_B.id ] ); + await caption( + page, + `beehiiv receives the newsletter in "Beta Weekly" only. Call made: POST /publications/${ targets[ 0 ] }/posts (AC-012)`, + 5000 + ); + await caption( + page, + 'Once the post is linked to beehiiv, the publication and template are locked (AC-016)', + 3500 + ); + await point( page, pub, 1500 ); + + // 4. Fallback when a stored publication is no longer connected --------------- + const staleId = createPost( 'Post from a removed publication', { + _beehiiv_publication_id: 'pub_qa_removed', + } ); + await caption( page, '4/4 · A post whose publication was disconnected', 0 ); + await openEditor( page, staleId ); + const notice = page.getByText( + /no longer connected.*default publication "Alpha Daily"/ + ); + await expect( notice ).toBeVisible(); + await point( page, notice, 1500 ); + await caption( + page, + 'The editor sees a notice. The send falls back to the default publication, "Alpha Daily" (AC-019)', + 4500 + ); + await publishPost( page ); + await expect( pub ).toBeDisabled( { timeout: 20000 } ); + const refresh = page + .locator( '.beehiiv-newsletter-publication' ) + .getByRole( 'button', { name: 'Refresh publications' } ); + await expect( refresh ).toBeDisabled(); + await point( page, refresh, 800 ); + await caption( + page, + 'Refresh publications is unavailable once the post is linked (AC-022)', + 3500 + ); + await caption( page, 'End of walkthrough', 2500 ); +} ); diff --git a/tests/e2e/demo/kit.js b/tests/e2e/demo/kit.js new file mode 100644 index 0000000..87c53fc --- /dev/null +++ b/tests/e2e/demo/kit.js @@ -0,0 +1,98 @@ +/** + * Shared helpers for intake demo scripts: on-screen captions, a visible + * cursor, paced clicks, and wp-cli seeding through the plugin's own E2E utils. + * A demo script (tests/e2e/demo/.demo.js) requires this file. + */ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin } = require( '../utils/auth' ); +const { openPostEditor } = require( '../utils/editor' ); +const wp = require( '../utils/wp-cli' ); + +const OVERLAY_JS = ` +( () => { + if ( window !== window.top ) { return; } + const boot = () => { + if ( document.getElementById( '__demo_caption' ) ) { return; } + const cap = document.createElement( 'div' ); + cap.id = '__demo_caption'; + cap.style.cssText = 'position:fixed;left:50%;bottom:28px;transform:translateX(-50%);max-width:880px;padding:14px 22px;border-radius:10px;background:rgba(17,17,17,.92);color:#fff;font:600 20px/1.35 system-ui,sans-serif;z-index:2147483647;text-align:center;pointer-events:none;display:none'; + const dot = document.createElement( 'div' ); + dot.id = '__demo_cursor'; + dot.style.cssText = 'position:fixed;left:-30px;top:-30px;width:22px;height:22px;margin:-11px 0 0 -11px;border-radius:50%;background:rgba(255,80,40,.55);border:2px solid #fff;box-shadow:0 0 0 2px rgba(255,80,40,.9);z-index:2147483647;pointer-events:none;transition:left .05s linear,top .05s linear'; + document.documentElement.append( cap, dot ); + window.addEventListener( 'mousemove', ( e ) => { dot.style.left = e.clientX + 'px'; dot.style.top = e.clientY + 'px'; }, true ); + }; + if ( document.documentElement ) { boot(); } else { document.addEventListener( 'DOMContentLoaded', boot ); } + document.addEventListener( 'DOMContentLoaded', boot ); +} )(); +`; + +/** + * Call once per page before navigating. + * @param {import('@playwright/test').Page} page + */ +async function installOverlay( page ) { + await page.addInitScript( OVERLAY_JS ); +} + +/** + * Shows a caption (empty text hides it) and holds for `hold` ms. + * @param {import('@playwright/test').Page} page + * @param {string} text + * @param {number} hold + */ +async function caption( page, text, hold = 2500 ) { + await page.evaluate( ( t ) => { + const el = document.getElementById( '__demo_caption' ); + if ( ! el ) { + return; + } + el.textContent = t; + el.style.display = t ? 'block' : 'none'; + }, text ); + if ( hold ) { + await page.waitForTimeout( hold ); + } +} + +/** + * Moves the visible cursor to the element, then clicks it. + * @param {import('@playwright/test').Page} page + * @param {import('@playwright/test').Locator} locator + */ +async function glideClick( page, locator ) { + await locator.scrollIntoViewIfNeeded(); + const box = await locator.boundingBox(); + await page.mouse.move( box.x + box.width / 2, box.y + box.height / 2, { + steps: 25, + } ); + await page.waitForTimeout( 400 ); + await locator.click(); +} + +/** + * Moves the cursor onto an element and pauses, to point at it. + * @param {import('@playwright/test').Page} page + * @param {import('@playwright/test').Locator} locator + * @param {number} hold + */ +async function point( page, locator, hold = 1500 ) { + await locator.scrollIntoViewIfNeeded(); + const box = await locator.boundingBox(); + await page.mouse.move( box.x + box.width / 2, box.y + box.height / 2, { + steps: 25, + } ); + await page.waitForTimeout( hold ); +} + +module.exports = { + test, + expect, + loginAsAdmin, + openPostEditor, + wp, + installOverlay, + caption, + glideClick, + point, +}; diff --git a/tests/e2e/demo/playwright.demo.config.js b/tests/e2e/demo/playwright.demo.config.js new file mode 100644 index 0000000..6640213 --- /dev/null +++ b/tests/e2e/demo/playwright.demo.config.js @@ -0,0 +1,44 @@ +/** + * Playwright config for the intake demo recordings (record-intake-demo skill). + * Runs one demo script with video on, at a pace a person can follow. + * + * Env: DEMO_SPEC (the demo script's file name in this folder), + * DEMO_OUT (output folder, default tests/e2e/test-results/demo/, which + * is gitignored and keeps the QA run's own results.json untouched), + * WP_BASE_URL (tests environment URL). + */ +const path = require( 'path' ); +const { defineConfig } = require( '@playwright/test' ); + +const spec = process.env.DEMO_SPEC; +const outDir = + process.env.DEMO_OUT || + path.join( + __dirname, + '../test-results/demo', + spec.replace( /\.demo\.js$/, '' ) + ); + +module.exports = defineConfig( { + testDir: __dirname, + testMatch: spec, + outputDir: path.join( outDir, 'raw' ), + timeout: 6 * 60 * 1000, + expect: { timeout: 8 * 1000 }, + fullyParallel: false, + workers: 1, + retries: 0, + reporter: [ + [ 'list' ], + [ 'json', { outputFile: path.join( outDir, 'results.json' ) } ], + ], + use: { + baseURL: process.env.WP_BASE_URL || 'http://localhost:8891', + viewport: { width: 1280, height: 720 }, + video: { mode: 'on', size: { width: 1280, height: 720 } }, + screenshot: 'off', + trace: 'off', + launchOptions: { slowMo: 120 }, + }, + projects: [ { name: 'chromium', use: { browserName: 'chromium' } } ], +} ); From 8e06843c35bbaec69339ebaccb876610b1e8c215 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Mon, 26 Oct 2026 16:49:13 +0800 Subject: [PATCH 2/7] test(e2e): add demo walkthrough for the require-template-choice intake Records the settings toggle, the empty template field, the blocked send and the unblocked send on the feature/19814561-settings-require-template build. --- tests/e2e/demo/c304d230.demo.js | 366 ++++++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 tests/e2e/demo/c304d230.demo.js diff --git a/tests/e2e/demo/c304d230.demo.js b/tests/e2e/demo/c304d230.demo.js new file mode 100644 index 0000000..95a394f --- /dev/null +++ b/tests/e2e/demo/c304d230.demo.js @@ -0,0 +1,366 @@ +// @demo-acs AC-013 AC-014 AC-015 AC-016 AC-017 AC-018 AC-019 +/** + * One-take demo for intake c304d230: setting to require editors to pick a post + * template (PRD admin-interface/publication-template-config, + * newsletter-publishing/post-template-selection and + * editor-integration/newsletter-template-selection). + * + * Run through the record-intake-demo skill (config: playwright.demo.config.js). + * State is arranged with the tests-environment seams the QA specs use + * (tests/e2e/plugins/beehiiv-options.php). Every action in the video goes + * through the real UI; wp-cli only seeds data and reads back state. + */ +const path = require( 'path' ); +const os = require( 'os' ); +const fs = require( 'fs' ); +const { execSync } = require( 'child_process' ); +const kit = require( './kit' ); + +const { test, expect, wp, installOverlay, caption, glideClick, point } = kit; + +const PUBLICATION_ID = 'pub_qa_e2e_nts'; +const DEFAULT_TEMPLATE = 'tpl_qa_e2e_nts_default'; +const OTHER_TEMPLATE = 'tpl_qa_e2e_nts_other'; +const REQUIRED_MESSAGE = + 'Choose a post template before sending this post to beehiiv.'; +const SETTINGS_PATH = '/wp-admin/admin.php?page=beehiiv'; +const TOGGLE = '#beehiiv_require_template_choice'; +const SIDEBAR = '.beehiiv-post-settings'; +const STATE = path.join( os.tmpdir(), 'demo-c304d230-auth.json' ); +const ids = {}; + +const b64 = ( v ) => Buffer.from( JSON.stringify( v ) ).toString( 'base64' ); +const evalWith = ( payload, code ) => + wp.wpCli( + `eval '$a = json_decode( base64_decode( "${ b64( + payload + ) }" ), true ); ${ code }'` + ); + +/** + * Seeds a connected site with one publication, two templates and the + * require-template setting left unset (off by default). + */ +function seed() { + evalWith( + {}, + `beehiiv_e2e_reset_all(); + beehiiv_e2e_seed_connection(); + beehiiv_e2e_mock_permissions( [ "posts" => [ "read", "write" ] ] ); + update_option( "beehiiv_settings", [ + "publication_id" => "${ PUBLICATION_ID }", + "post_template_id" => "${ DEFAULT_TEMPLATE }", + ] ); + beehiiv_e2e_seed_publications( [ [ "id" => "${ PUBLICATION_ID }", "name" => "QA E2E Publication" ] ] ); + beehiiv_e2e_seed_post_templates( "${ PUBLICATION_ID }", [ + [ "id" => "${ DEFAULT_TEMPLATE }", "name" => "QA Default Template" ], + [ "id" => "${ OTHER_TEMPLATE }", "name" => "QA Other Template" ], + ] ); + beehiiv_e2e_mock_http( "/post_templates", [ "body" => [ "data" => [ + [ "id" => "${ DEFAULT_TEMPLATE }", "name" => "QA Default Template" ], + [ "id" => "${ OTHER_TEMPLATE }", "name" => "QA Other Template" ], + ] ] ] ); + beehiiv_e2e_mock_http( "/posts", [ "body" => [ "data" => [ "id" => "post_qa_e2e_nts_created" ] ] ] ); + beehiiv_e2e_clear_http_log();` + ); +} + +/** + * Creates a never-sent draft with "Send to newsletter" on and returns its ID. + * + * @param {string} title Post title. + * @return {string} The new post ID. + */ +function createPost( title ) { + return evalWith( + { + post: { + post_type: 'post', + post_title: title, + post_content: + '

Everything we shipped this quarter, in one place.

', + post_status: 'draft', + post_author: 1, + }, + }, + `$id = wp_insert_post( $a["post"] ); + update_post_meta( $id, "_beehiiv_send_to_newsletter", "1" ); + echo $id . "\\n";` + ).trim(); +} + +/** + * Reads the stored value of the require-template setting. + * + * @return {boolean} The stored setting. + */ +function storedSetting() { + const out = wp.wpCli( + `eval '$s = get_option( "beehiiv_settings" ); echo "V=" . ( ! empty( $s["require_template_choice"] ) ? "1" : "0" );'` + ); + return /V=1/.test( out ); +} + +/** + * Create-post calls the plugin made to beehiiv since the log was last cleared. + * + * @return {string[]} URLs of POST .../posts calls. + */ +function createCalls() { + const out = wp.wpCli( + `eval 'echo "JSON=" . wp_json_encode( array_values( (array) get_option( "beehiiv_e2e_http_log", [] ) ) );'` + ); + return JSON.parse( out.slice( out.indexOf( 'JSON=' ) + 5 ) ) + .filter( + ( e ) => + e.method === 'POST' && + /\/publications\/[^/]+\/posts/.test( e.url ) + ) + .map( ( e ) => e.url ); +} + +/** + * Opens a post in the editor with the beehiiv sidebar and template field shown. + * + * @param {import('@playwright/test').Page} page Page. + * @param {string} postId Post ID. + * @param {boolean} field Wait for the template field, not just the panel. + * @return {Promise} The beehiiv sidebar. + */ +async function openSidebarFor( page, postId, field = true ) { + await kit.openPostEditor( page, postId ); + const sidebar = page.locator( SIDEBAR ); + // The editor remembers an open plugin sidebar, so only click when closed. + const toggle = page + .getByRole( 'button', { name: 'beehiiv', exact: true } ) + .first(); + await toggle.waitFor( { state: 'visible', timeout: 15000 } ); + const open = + ( await toggle.getAttribute( 'aria-expanded' ) ) === 'true' || + ( await toggle.getAttribute( 'aria-pressed' ) ) === 'true'; + if ( ! open ) { + await toggle.click(); + } + await expect( + field + ? sidebar.getByLabel( 'Post template' ) + : page.locator( '.beehiiv-post-settings-content' ) + ).toBeVisible( { timeout: 20000 } ); + return sidebar; +} + +/** + * Publishes the open post through the real editor save path. + * + * @param {import('@playwright/test').Page} page Page. + */ +async function publish( page ) { + await page.evaluate( async () => { + const { dispatch } = window.wp.data; + dispatch( 'core/editor' ).editPost( { status: 'publish' } ); + await dispatch( 'core/editor' ).savePost(); + } ); +} + +/** + * Sets the settings-page toggle through the UI and saves. + * + * @param {import('@playwright/test').Page} page Page. + * @param {boolean} on Wanted state. + */ +async function setToggle( page, on ) { + await glideClick( page, page.locator( TOGGLE ) ); + await expect( page.locator( TOGGLE ) ).toBeChecked( { checked: on } ); + await page.waitForTimeout( 1500 ); + await glideClick( page, page.locator( '#submit' ) ); + await page.waitForURL( /page=beehiiv/ ); + await expect( page.locator( TOGGLE ) ).toBeChecked( { checked: on } ); +} + +test.use( { storageState: STATE, viewport: { width: 1280, height: 720 } } ); + +test.beforeAll( async ( { browser } ) => { + wp.ensurePluginActive(); + seed(); + ids.first = createPost( 'Quarterly roundup' ); + ids.second = createPost( 'Product launch notes' ); + ids.third = createPost( 'Holiday sale announcement' ); + // Log in before recording starts, so the video opens on the product. + const ctx = await browser.newContext( { + storageState: undefined, + baseURL: process.env.WP_BASE_URL || 'http://localhost:8891', + } ); + const page = await ctx.newPage(); + await kit.loginAsAdmin( page ); + await ctx.storageState( { path: STATE } ); + await ctx.close(); +} ); + +test.afterAll( () => { + evalWith( + { ids: Object.values( ids ) }, + `foreach ( $a["ids"] as $id ) { wp_delete_post( (int) $id, true ); } + delete_option( "beehiiv_settings" ); beehiiv_e2e_reset_all();` + ); + fs.rmSync( STATE, { force: true } ); +} ); + +test( 'Require editors to choose a post template: full walkthrough', async ( { + page, +} ) => { + await installOverlay( page ); + const build = execSync( 'git rev-parse --short HEAD', { + cwd: path.resolve( __dirname, '../../..' ), + } ) + .toString() + .trim(); + + // 1. Settings: turn the requirement on -------------------------------------- + await page.goto( SETTINGS_PATH ); + await expect( page.locator( TOGGLE ) ).toBeVisible(); + // Keep the toggle clear of the caption bar at the bottom of the screen. + await page + .locator( TOGGLE ) + .evaluate( ( el ) => el.scrollIntoView( { block: 'center' } ) ); + await caption( + page, + `Require editors to choose a post template · beehiiv plugin build ${ build }`, + 3500 + ); + await expect( page.locator( TOGGLE ) ).not.toBeChecked(); + await caption( + page, + '1/4 · Plugin Settings has a new toggle: "Require editors to choose a post template". It is off by default (AC-014, AC-015)', + 1200 + ); + await point( + page, + page.getByLabel( 'Require editors to choose a post template' ), + 4500 + ); + const help = page + .locator( 'td' ) + .filter( { has: page.locator( TOGGLE ) } ) + .locator( 'p.description' ); + await expect( help ).toContainText( + 'sending is blocked until a template is chosen' + ); + await caption( + page, + 'The help text explains the rule: the default template is not applied automatically, and sending is blocked until a template is chosen (AC-017)', + 1200 + ); + await point( page, help, 5500 ); + await caption( page, 'Turn it on and save', 1000 ); + await setToggle( page, true ); + expect( storedSetting() ).toBe( true ); + await caption( + page, + 'The setting is saved and stays on after the page reloads (AC-016)', + 4000 + ); + + // 2. Editor: the template field starts empty -------------------------------- + await caption( + page, + '2/4 · A new post that has never been sent to beehiiv', + 0 + ); + let sidebar = await openSidebarFor( page, ids.first ); + const select = sidebar.getByLabel( 'Post template' ); + await expect( select ).toHaveValue( '' ); + await expect( select.locator( 'option:checked' ) ).toHaveText( + 'Choose a template' + ); + await caption( + page, + 'The Post template field starts empty with the prompt "Choose a template". No default is pre-selected (AC-014)', + 1200 + ); + await point( page, select, 4500 ); + await expect( + select.locator( 'option', { + hasText: 'Default (from beehiiv settings)', + } ) + ).toHaveCount( 0 ); + await caption( + page, + 'The "Default (from beehiiv settings)" option is not offered. Real templates stay selectable (AC-015)', + 4500 + ); + const helpText = sidebar + .locator( '.beehiiv-newsletter-template' ) + .locator( '.components-base-control__help' ); + await expect( helpText ).toHaveText( REQUIRED_MESSAGE ); + await point( page, helpText, 800 ); + await caption( + page, + 'Help text under the field asks the editor to choose a post template (AC-016)', + 4500 + ); + + // 3. Publishing without a template is blocked -------------------------------- + await caption( page, '3/4 · Publish without choosing a template', 1500 ); + await publish( page ); + sidebar = await openSidebarFor( page, ids.first, false ); + const errorNotice = sidebar + .locator( '.components-notice.is-error' ) + .filter( { hasText: REQUIRED_MESSAGE } ) + .first(); + await expect( errorNotice ).toBeVisible(); + expect( createCalls() ).toEqual( [] ); + await point( page, errorNotice, 800 ); + await caption( + page, + 'The send is blocked with an error notice, even though a default template exists. Nothing is sent to beehiiv (AC-013, AC-017)', + 6000 + ); + + // 4. Choosing a template lets the send continue ------------------------------- + sidebar = await openSidebarFor( page, ids.second ); + const select2 = sidebar.getByLabel( 'Post template' ); + await expect( sidebar.getByText( REQUIRED_MESSAGE ).first() ).toBeVisible(); + await caption( + page, + 'Another new post. The editor picks "QA Other Template"', + 1200 + ); + await glideClick( page, select2 ); + await select2.selectOption( OTHER_TEMPLATE ); + await expect( select2 ).toHaveValue( OTHER_TEMPLATE ); + await expect( sidebar.getByText( REQUIRED_MESSAGE ) ).toHaveCount( 0 ); + await caption( + page, + 'The prompt and help text clear as soon as a template is chosen (AC-018)', + 4500 + ); + await publish( page ); + await expect + .poll( () => createCalls().length, { timeout: 20000 } ) + .toBe( 1 ); + expect( createCalls()[ 0 ] ).toContain( + `/publications/${ PUBLICATION_ID }/posts` + ); + await caption( + page, + 'With a template chosen, the send continues. The newsletter is created in beehiiv (AC-017)', + 5000 + ); + + // 5. Setting off: behavior is unchanged --------------------------------------- + await caption( page, '4/4 · Turn the setting off again', 1200 ); + await page.goto( SETTINGS_PATH ); + await setToggle( page, false ); + expect( storedSetting() ).toBe( false ); + sidebar = await openSidebarFor( page, ids.third ); + const select3 = sidebar.getByLabel( 'Post template' ); + await expect( select3 ).toHaveValue( DEFAULT_TEMPLATE ); + await expect( sidebar.getByText( REQUIRED_MESSAGE ) ).toHaveCount( 0 ); + await point( page, select3, 800 ); + await caption( + page, + 'With the setting off, new posts use the default template and show no prompt, as before (AC-019)', + 5500 + ); + await caption( page, 'End of walkthrough', 2500 ); +} ); From 67537e55a2aa6e002a07d6825d81a42a121fcf8a Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Mon, 26 Oct 2026 20:21:24 +0800 Subject: [PATCH 3/7] test(e2e): show the pre-publish panel notice in the require-template demo --- tests/e2e/demo/c304d230.demo.js | 39 ++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/tests/e2e/demo/c304d230.demo.js b/tests/e2e/demo/c304d230.demo.js index 95a394f..995167e 100644 --- a/tests/e2e/demo/c304d230.demo.js +++ b/tests/e2e/demo/c304d230.demo.js @@ -300,8 +300,41 @@ test( 'Require editors to choose a post template: full walkthrough', async ( { ); // 3. Publishing without a template is blocked -------------------------------- - await caption( page, '3/4 · Publish without choosing a template', 1500 ); - await publish( page ); + await caption( + page, + '3/4 · Click Publish without choosing a template', + 1500 + ); + await glideClick( + page, + page.locator( '.editor-post-publish-panel__toggle' ) + ); + const prePublish = page.locator( '.editor-post-publish-panel' ); + await expect( prePublish ).toBeVisible(); + const prePublishNotice = prePublish + .locator( '.components-notice.is-error' ) + .filter( { hasText: REQUIRED_MESSAGE } ) + .first(); + await expect( prePublishNotice ).toBeVisible(); + await point( page, prePublishNotice, 800 ); + await caption( + page, + 'The pre-publish panel already warns the editor with an error notice: choose a post template before sending', + 6000 + ); + await caption( page, 'Publish anyway', 1200 ); + await glideClick( + page, + prePublish.locator( '.editor-post-publish-button' ) + ); + await page.waitForFunction( + () => + window.wp.data + .select( 'core/editor' ) + .getCurrentPostAttribute( 'status' ) === 'publish', + null, + { timeout: 20000 } + ); sidebar = await openSidebarFor( page, ids.first, false ); const errorNotice = sidebar .locator( '.components-notice.is-error' ) @@ -312,7 +345,7 @@ test( 'Require editors to choose a post template: full walkthrough', async ( { await point( page, errorNotice, 800 ); await caption( page, - 'The send is blocked with an error notice, even though a default template exists. Nothing is sent to beehiiv (AC-013, AC-017)', + 'The post publishes in WordPress, but the send is blocked with an error notice, even though a default template exists. Nothing is sent to beehiiv (AC-013, AC-017)', 6000 ); From 6c5029c5be359b4e36404a198afa9ec5599f42c7 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 27 Oct 2026 17:10:30 +0800 Subject: [PATCH 4/7] test(e2e): add demo walkthrough for the email preview intake Records the Preview email control, the sandboxed preview modal, the save-before-preview rule, a failed preview with retry, and the unavailable state on an already scheduled post. --- tests/e2e/demo/862095db.demo.js | 403 ++++++++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 tests/e2e/demo/862095db.demo.js diff --git a/tests/e2e/demo/862095db.demo.js b/tests/e2e/demo/862095db.demo.js new file mode 100644 index 0000000..8fd0cac --- /dev/null +++ b/tests/e2e/demo/862095db.demo.js @@ -0,0 +1,403 @@ +// @demo-acs AC-001 AC-002 AC-003 AC-005 AC-008 AC-010 AC-013 AC-015 +/** + * One-take demo for intake 862095db: email preview in the WordPress post + * editor (PRD-06.5.02 Email Preview). + * + * Run through the record-intake-demo skill (config: playwright.demo.config.js). + * State is arranged with the tests-environment seams the QA spec uses + * (tests/e2e/plugins/beehiiv-options.php). beehiiv itself is mocked: the + * rendered email below stands in for the real beehiiv email. Every action in + * the video goes through the real editor UI; wp-cli only seeds data and reads + * back which calls the plugin made to beehiiv. + */ +const path = require( 'path' ); +const os = require( 'os' ); +const fs = require( 'fs' ); +const { execSync } = require( 'child_process' ); +const kit = require( './kit' ); +const { saveDraft } = require( '../utils/editor' ); + +const { test, expect, wp, installOverlay, caption, glideClick, point } = kit; + +const PUB = 'pub_e2e'; +const TPL = 'tpl_e2e'; +const DRAFT_ID = 'post_preview_e2e'; +const LINKED_ID = 'post_linked_e2e'; +const ORIGIN = 'http://localhost:8891'; +const STATE = path.join( os.tmpdir(), 'demo-862095db-auth.json' ); +const ids = {}; + +const EMAIL_HTML = + '' + + '
' + + '
The Acme Weekly
' + + '
' + + '

Three things worth knowing this week

' + + '

Everything we shipped, in one place. This is the email your subscribers will receive, rendered by beehiiv with your template, header and footer.

' + + `

Read the full story

` + + '' + + '
' + + '
You are receiving this because you subscribed to The Acme Weekly. Unsubscribe
' + + '
'; + +const b64 = ( v ) => Buffer.from( v ).toString( 'base64' ); +const arg = ( v ) => + `json_decode( base64_decode( "${ b64( JSON.stringify( v ) ) }" ), true )`; +const php = ( code ) => + wp.wpCli( `eval 'eval( base64_decode( "${ b64( code ) }" ) );'` ); + +/** + * Seeds a connected, ready site with the beehiiv post endpoints mocked. + * + * @param {number} emailStatus HTTP status the "fetch email" call returns. + */ +function seed( emailStatus = 200 ) { + const ok = emailStatus === 200; + const mocks = { + create_post: { + pattern: '#/publications/[^/]+/posts(\\?|$)#', + method: 'POST', + status: 201, + body: { data: { id: DRAFT_ID } }, + }, + update_post: { + pattern: '#/publications/[^/]+/posts/[^/?]+(\\?|$)#', + method: 'PATCH', + status: 200, + body: { data: { id: DRAFT_ID } }, + }, + get_email: { + pattern: '#/publications/[^/]+/posts/[^/?]+\\?#', + method: 'GET', + status: emailStatus, + body: ok + ? { + data: { + id: DRAFT_ID, + content: { free: { email: EMAIL_HTML } }, + }, + } + : { message: 'beehiiv is temporarily unavailable' }, + }, + }; + php( ` + beehiiv_e2e_reset_all(); + beehiiv_e2e_seed_connection(); + beehiiv_e2e_mock_permissions( ${ arg( { posts: [ 'read', 'write' ] } ) } ); + beehiiv_e2e_seed_publications( [ [ "id" => "${ PUB }", "name" => "The Acme Weekly" ] ] ); + remove_all_filters( "sanitize_option_beehiiv_settings" ); + update_option( "beehiiv_settings", ${ arg( { + publication_id: PUB, + post_template_id: TPL, + } ) } ); + foreach ( ${ arg( + mocks + ) } as $key => $mock ) { beehiiv_e2e_mock_http( $key, $mock ); } + beehiiv_e2e_clear_http_log(); + ` ); +} + +/** + * Creates a post with "Send to newsletter" on and returns its ID. + * + * @param {string} title Post title. + * @param {Object} meta Extra post meta. + * @return {string} The new post ID. + */ +function createPost( title, meta = {} ) { + return php( ` + $id = wp_insert_post( ${ arg( { + post_type: 'post', + post_title: title, + post_status: 'draft', + post_author: 1, + post_content: + '

Everything we shipped, in one place.

', + } ) } ); + foreach ( ${ arg( { + _beehiiv_send_to_newsletter: '1', + ...meta, + } ) } as $k => $v ) { update_post_meta( $id, $k, $v ); } + echo "ID=" . $id; + ` ).match( /ID=(\d+)/ )[ 1 ]; +} + +/** + * Calls the plugin made to beehiiv, as "METHOD name" in order, with bodies. + * + * @return {Array} Log entries. + */ +function beehiivCalls() { + const out = php( + 'echo "JSON=" . wp_json_encode( array_values( (array) get_option( "beehiiv_e2e_http_log", [] ) ) );' + ); + return JSON.parse( out.slice( out.indexOf( 'JSON=' ) + 5 ) ).filter( + ( e ) => e.mock !== '/workspaces/permissions' + ); +} + +const labels = ( calls ) => calls.map( ( c ) => `${ c.method } ${ c.mock }` ); +const clearLog = () => php( 'beehiiv_e2e_clear_http_log();' ); + +const previewButton = ( page ) => + page.getByRole( 'button', { name: 'Preview email', exact: true } ); +const modal = ( page ) => page.getByRole( 'dialog', { name: 'Email preview' } ); +const emailFrame = ( page ) => modal( page ).locator( 'iframe' ); + +/** + * Closes the preview modal with its Close button, as an editor would. + * + * @param {import('@playwright/test').Page} page Page. + */ +async function closeModal( page ) { + await glideClick( + page, + modal( page ).getByRole( 'button', { name: 'Close' } ).first() + ); + await expect( modal( page ) ).toHaveCount( 0 ); +} + +/** + * Opens a post in the editor with the beehiiv panel visible. + * + * @param {import('@playwright/test').Page} page Page. + * @param {string} postId Post ID. + */ +async function openPanel( page, postId ) { + await kit.openPostEditor( page, postId ); + const toggle = page + .getByRole( 'button', { name: 'beehiiv', exact: true } ) + .first(); + await toggle.waitFor( { state: 'visible', timeout: 15000 } ); + const open = + ( await toggle.getAttribute( 'aria-expanded' ) ) === 'true' || + ( await toggle.getAttribute( 'aria-pressed' ) ) === 'true'; + if ( ! open ) { + await toggle.click(); + } + await expect( + page.locator( '.beehiiv-post-settings-content' ) + ).toBeVisible(); +} + +test.use( { storageState: STATE, viewport: { width: 1280, height: 720 } } ); + +test.beforeAll( async ( { browser } ) => { + wp.ensurePluginActive(); + wp.ensurePrettyPermalinks(); + seed(); + ids.draft = createPost( 'Acme Weekly: October issue' ); + ids.scheduled = createPost( 'Acme Weekly: September issue', { + _beehiiv_post_id: LINKED_ID, + _beehiiv_scheduled_at: new Date( Date.now() + 2 * 86400000 ) + .toISOString() + .replace( /\.\d{3}Z$/, 'Z' ), + } ); + // Log in before recording starts, so the video opens on the product. + const ctx = await browser.newContext( { + storageState: undefined, + baseURL: process.env.WP_BASE_URL || 'http://localhost:8891', + } ); + const page = await ctx.newPage(); + await kit.loginAsAdmin( page ); + await ctx.storageState( { path: STATE } ); + await ctx.close(); +} ); + +test.afterAll( () => { + php( ` + foreach ( ${ arg( Object.values( ids ) ) } as $id ) { + delete_post_meta( (int) $id, "_beehiiv_post_id" ); + wp_delete_post( (int) $id, true ); + } + beehiiv_e2e_reset_all(); + delete_option( "beehiiv_settings" ); + ` ); + fs.rmSync( STATE, { force: true } ); +} ); + +test( 'Email preview in the post editor: full walkthrough', async ( { + page, + context, +} ) => { + await installOverlay( page ); + await page.addInitScript( () => { + window.__e2eMessages = []; + window.addEventListener( 'message', ( e ) => { + window.__e2eMessages.push( e.data ); + } ); + } ); + const build = execSync( 'git rev-parse --short HEAD', { + cwd: path.resolve( __dirname, '../../..' ), + } ) + .toString() + .trim(); + + // 1. The Preview control --------------------------------------------------- + await openPanel( page, ids.draft ); + await caption( + page, + `Email preview in the post editor · beehiiv plugin build ${ build }`, + 3500 + ); + await caption( + page, + '1/4 · A post marked "Send to newsletter". The beehiiv panel has a "Preview email" control (AC-001)', + 1200 + ); + await expect( previewButton( page ) ).toBeEnabled(); + await point( page, previewButton( page ), 4500 ); + + // 2. Open the preview ------------------------------------------------------- + await caption( page, 'Click Preview email', 1200 ); + clearLog(); + await glideClick( page, previewButton( page ) ); + await expect( emailFrame( page ) ).toBeVisible( { timeout: 40000 } ); + const frame = page.frameLocator( '.beehiiv-email-preview__modal iframe' ); + await expect( frame.locator( 'h1' ) ).toHaveText( + 'Three things worth knowing this week' + ); + await caption( + page, + 'A WordPress modal shows the email as beehiiv rendered it, with template, header and footer (AC-002)', + 6000 + ); + const first = beehiivCalls(); + expect( labels( first ) ).toEqual( [ + 'POST create_post', + 'GET get_email', + ] ); + expect( first[ 0 ].body.status ).toBe( 'draft' ); + expect( first[ 0 ].body.title ).toBe( 'Acme Weekly: October issue' ); + await caption( + page, + 'Behind the scenes: the plugin created a beehiiv draft from the saved post, then fetched its email (AC-003)', + 6000 + ); + + // 3. The preview is view-only ------------------------------------------------- + const sandbox = await emailFrame( page ).getAttribute( 'sandbox' ); + expect( sandbox ).not.toBeNull(); + expect( sandbox.trim() ).toBe( '' ); + await caption( + page, + '2/4 · The email is shown in a sandboxed frame. Scripts are off and links do nothing. Try clicking the link…', + 1500 + ); + await point( page, frame.locator( '#plain' ), 1200 ); + await frame.locator( '#plain' ).click( { force: true, timeout: 5000 } ); + await page.waitForTimeout( 2500 ); + expect( await page.evaluate( () => window.__e2eMessages ) ).toEqual( [] ); + expect( context.pages() ).toHaveLength( 1 ); + await expect( modal( page ) ).toBeVisible(); + await expect( frame.locator( 'h1' ) ).toBeVisible(); + await caption( + page, + 'Nothing happened: no navigation, no new tab, and the email script did not run (AC-005)', + 5000 + ); + + // 4. Edits: unsaved blocks Preview, saving refreshes it -------------------- + await caption( page, '3/4 · Close the preview and edit the title', 1500 ); + await closeModal( page ); + await expect( modal( page ) ).toHaveCount( 0 ); + const title = page + .frameLocator( 'iframe[name="editor-canvas"]' ) + .locator( '.editor-post-title__input, [aria-label="Add title"]' ) + .first(); + await glideClick( page, title ); + // Put the caret at the end of the title, on any platform. + await title.evaluate( ( el ) => { + const range = el.ownerDocument.createRange(); + range.selectNodeContents( el ); + range.collapse( false ); + const selection = el.ownerDocument.defaultView.getSelection(); + selection.removeAllRanges(); + selection.addRange( range ); + } ); + await page.keyboard.type( ' (final)', { delay: 90 } ); + await expect( + page.locator( '.beehiiv-post-settings-content' ) + ).toContainText( 'Save the post' ); + await expect( previewButton( page ) ).toBeDisabled(); + await point( page, previewButton( page ), 800 ); + await caption( + page, + 'Preview is disabled until the post is saved. The plugin never saves for the editor (AC-008)', + 5500 + ); + await saveDraft( page ); + await expect( previewButton( page ) ).toBeEnabled( { timeout: 15000 } ); + clearLog(); + await caption( page, 'Save the draft, then preview again', 1500 ); + await glideClick( page, previewButton( page ) ); + await expect( emailFrame( page ) ).toBeVisible( { timeout: 40000 } ); + const second = beehiivCalls(); + expect( labels( second ) ).toEqual( [ + 'PATCH update_post', + 'GET get_email', + ] ); + expect( JSON.stringify( second[ 0 ].body ) ).toContain( + 'Acme Weekly: October issue (final)' + ); + await caption( + page, + 'Every click re-syncs the saved post first. This time the same beehiiv draft is updated with the new title, not created again (AC-003)', + 6500 + ); + + // 5. A failure, then retry in the same modal ----------------------------------- + await closeModal( page ); + await expect( modal( page ) ).toHaveCount( 0 ); + seed( 500 ); + await caption( + page, + 'When beehiiv fails, the editor sees a clear message instead of a blank modal…', + 1500 + ); + await glideClick( page, previewButton( page ) ); + await expect( modal( page ) ).toContainText( + 'beehiiv is temporarily unavailable', + { timeout: 30000 } + ); + await expect( emailFrame( page ) ).toHaveCount( 0 ); + const retry = modal( page ).getByRole( 'button', { name: 'Try again' } ); + await expect( retry ).toBeVisible(); + await point( page, retry, 800 ); + await caption( + page, + 'The modal shows beehiiv\'s message and a "Try again" button (AC-013)', + 5000 + ); + seed( 200 ); + await caption( page, 'beehiiv recovers. Click Try again', 1200 ); + await glideClick( page, retry ); + await expect( emailFrame( page ) ).toBeVisible( { timeout: 40000 } ); + await expect( modal( page ) ).not.toContainText( + 'temporarily unavailable' + ); + await caption( + page, + 'The preview loads in the same modal, without closing it (AC-015)', + 5000 + ); + await closeModal( page ); + + // 6. Already scheduled posts ------------------------------------------------------ + await caption( + page, + '4/4 · A post that is already scheduled in beehiiv', + 0 + ); + await openPanel( page, ids.scheduled ); + await expect( previewButton( page ) ).toHaveCount( 0 ); + const reason = page.locator( '.beehiiv-email-preview__reason' ); + await expect( reason ).toContainText( 'already scheduled or sent' ); + await point( page, reason, 800 ); + await caption( + page, + "No Preview button. A short reason says this newsletter is already scheduled or sent, so it can't be previewed (AC-010)", + 6000 + ); + await caption( page, 'End of walkthrough', 2500 ); +} ); From 1f058cedbfe4052941d6cdb8bd2f537b7f584077 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 27 Oct 2026 18:48:09 +0800 Subject: [PATCH 5/7] test(e2e): add mobile view to the email preview demo --- tests/e2e/demo/862095db.demo.js | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/e2e/demo/862095db.demo.js b/tests/e2e/demo/862095db.demo.js index 8fd0cac..ece260f 100644 --- a/tests/e2e/demo/862095db.demo.js +++ b/tests/e2e/demo/862095db.demo.js @@ -275,6 +275,43 @@ test( 'Email preview in the post editor: full walkthrough', async ( { 6000 ); + // 2b. Desktop and Mobile views --------------------------------------------------- + const deviceFrame = modal( page ).locator( + '.beehiiv-email-preview__device' + ); + const deviceButton = ( name ) => + modal( page ).getByRole( 'button', { name, exact: true } ); + await expect( deviceButton( 'Desktop' ) ).toHaveAttribute( + 'aria-pressed', + 'true' + ); + const desktopWidth = ( await deviceFrame.boundingBox() ).width; + await caption( + page, + 'The modal also has a Mobile view. Click Mobile', + 1500 + ); + await glideClick( page, deviceButton( 'Mobile' ) ); + await expect( deviceButton( 'Mobile' ) ).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await expect( deviceFrame ).toHaveClass( /is-mobile/ ); + await expect( frame.locator( 'h1' ) ).toBeVisible(); + const mobileWidth = ( await deviceFrame.boundingBox() ).width; + expect( mobileWidth ).toBeLessThanOrEqual( 448 ); + expect( mobileWidth ).toBeLessThan( desktopWidth ); + await caption( + page, + 'Mobile view: the same email in a phone-width frame, so the editor sees how it reads on a phone', + 6500 + ); + await caption( page, 'Switch back to Desktop', 1200 ); + await glideClick( page, deviceButton( 'Desktop' ) ); + await expect( deviceFrame ).toHaveClass( /is-desktop/ ); + await expect( frame.locator( 'h1' ) ).toBeVisible(); + await caption( page, 'Back on the Desktop view', 2000 ); + // 3. The preview is view-only ------------------------------------------------- const sandbox = await emailFrame( page ).getAttribute( 'sandbox' ); expect( sandbox ).not.toBeNull(); From 1ed01091234d72c805ef62d80d855d2bcce38085 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 23:11:43 +0800 Subject: [PATCH 6/7] test(e2e): add demo walkthrough for the subscriber gating intake --- tests/e2e/demo/19ec1a76.demo.js | 329 ++++++++++++++++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 tests/e2e/demo/19ec1a76.demo.js diff --git a/tests/e2e/demo/19ec1a76.demo.js b/tests/e2e/demo/19ec1a76.demo.js new file mode 100644 index 0000000..bcf11ae --- /dev/null +++ b/tests/e2e/demo/19ec1a76.demo.js @@ -0,0 +1,329 @@ +// @demo-acs AC-001 AC-005 AC-009 AC-011 AC-012 AC-013 AC-016 AC-018 AC-019 AC-021 AC-022 AC-026 AC-032 +/** + * One-take demo for intake 19ec1a76: subscriber gating with the Gated Content + * block (PRD-05.4.01) and its site defaults (PRD-08.1.03). + * + * Run through the record-intake-demo skill (config: playwright.demo.config.js). + * State is arranged with wp-cli. Every action in the video goes through the + * real UI. beehiiv's own servers are blocked while recording, so the form + * itself does not load. The submit is the same message beehiiv's form sends + * when a reader subscribes (type, form ID, beehiiv origin), and the captions + * say so. + */ +const path = require( 'path' ); +const os = require( 'os' ); +const fs = require( 'fs' ); +const kit = require( './kit' ); +const { test, expect, wp, installOverlay, caption, glideClick, point } = kit; + +const FORM_ID = 'demo-form-7ba8db0b'; +const COOKIE = 'beehiiv_gate_unlocked'; +const STATE = path.join( os.tmpdir(), 'demo-19ec1a76-auth.json' ); +const ids = []; + +const php = ( code ) => + wp.wpCli( + `eval 'eval( base64_decode( "${ Buffer.from( code ).toString( + 'base64' + ) }" ) );'` + ); + +function seed() { + php( ` + beehiiv_e2e_reset_all(); + delete_option( "beehiiv_settings" ); + ` ); +} + +function createPost( title ) { + const body = + '

Monday: the story opens here, and every reader can see these first lines.

'; + const out = php( ` + $id = wp_insert_post( wp_slash( array( + "post_type" => "post", + "post_title" => ${ JSON.stringify( title ) }, + "post_status" => "draft", + "post_content" => ${ JSON.stringify( body ) }, + ) ) ); + echo "ID=" . $id; + ` ); + const id = Number( /ID=(\d+)/.exec( out )[ 1 ] ); + ids.push( id ); + return id; +} + +async function dismissGuide( page ) { + const close = page.locator( '.components-modal__header button' ).first(); + if ( await close.isVisible().catch( () => false ) ) { + await close.click(); + } +} + +async function submitForm( page, formId ) { + await page.evaluate( ( id ) => { + window.dispatchEvent( + new MessageEvent( 'message', { + origin: 'https://subscribe-forms.beehiiv.com', + data: { + type: 'BEEHIIV_SUBSCRIBER_FORM_SUBMITTED', + externalEmbedId: id, + }, + } ) + ); + }, formId ); +} + +test.use( { storageState: STATE, viewport: { width: 1280, height: 720 } } ); + +test.beforeAll( async ( { browser } ) => { + wp.ensurePluginActive(); + wp.ensurePrettyPermalinks(); + seed(); + // Log in before recording starts, so the video opens on the product. + const ctx = await browser.newContext( { + storageState: undefined, + baseURL: process.env.WP_BASE_URL || 'http://localhost:8891', + } ); + const page = await ctx.newPage(); + await kit.loginAsAdmin( page ); + await ctx.storageState( { path: STATE } ); + await ctx.close(); +} ); + +test.afterAll( () => { + php( ` + beehiiv_e2e_reset_all(); + foreach ( array_filter( explode( ",", "${ ids.join( + ',' + ) }" ) ) as $id ) { wp_delete_post( (int) $id, true ); } + delete_option( "beehiiv_settings" ); + ` ); + fs.rmSync( STATE, { force: true } ); +} ); + +test( 'Subscriber gating: full walkthrough', async ( { page } ) => { + await installOverlay( page ); + // beehiiv's servers are not reachable from a recording, and no real + // subscription should be made. The form area stays empty. + await page.route( '**/subscribe-forms.beehiiv.com/**', ( route ) => + route.abort() + ); + + // 1. Settings: site-wide defaults ----------------------------------------- + await page.goto( '/wp-admin/admin.php?page=beehiiv' ); + await expect( + page.getByRole( 'heading', { name: 'Subscriber gating' } ) + ).toBeVisible(); + await caption( + page, + 'Subscriber gating · demo of the beehiiv plugin build', + 3000 + ); + await caption( + page, + '1/4 · Settings: a new "Subscriber gating" section holds the site defaults', + 1500 + ); + await point( + page, + page.getByRole( 'heading', { name: 'Subscriber gating' } ), + 1500 + ); + const formField = page.locator( '#beehiiv_gate_form_id' ); + await glideClick( page, formField ); + await formField.fill( FORM_ID ); + await caption( + page, + 'Set the default subscribe form once. Gated blocks use it unless they have their own form ID.', + 3500 + ); + const headline = page.locator( '#beehiiv_gate_headline' ); + await glideClick( page, headline ); + await headline.fill( 'Subscribe to keep reading' ); + const text = page.locator( '#beehiiv_gate_text' ); + await glideClick( page, text ); + await text.fill( 'Enter your email to unlock the full story.' ); + await caption( + page, + 'The headline and text are optional. Leave them blank to show only the form.', + 3500 + ); + const days = page.locator( '#beehiiv_gate_cookie_days' ); + await glideClick( page, days ); + await days.fill( '30' ); + await caption( + page, + 'A reader stays unlocked for 30 days after subscribing (1 to 365)', + 3000 + ); + await glideClick( + page, + page.getByRole( 'button', { name: 'Save settings' } ) + ); + await expect( page.locator( '#beehiiv_gate_form_id' ) ).toHaveValue( + FORM_ID + ); + await caption( page, 'Saved', 1500 ); + + // 2. Editor: add the Gated Content block -------------------------------- + const postId = createPost( 'The weekly briefing' ); + await caption( page, '2/4 · Post editor: add the Gated Content block', 0 ); + await page.goto( `/wp-admin/post.php?post=${ postId }&action=edit` ); + await page.waitForSelector( 'iframe[name="editor-canvas"]', { + timeout: 20000, + } ); + await dismissGuide( page ); + const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' ); + await caption( + page, + 'The post starts with one paragraph that everyone will see', + 2500 + ); + await glideClick( + page, + page.getByRole( 'button', { name: /Block Inserter/i } ).first() + ); + const search = page.getByPlaceholder( /^Search/ ).first(); + await search.fill( 'Gated Content' ); + const item = page + .getByRole( 'option', { name: /beehiiv Gated Content/ } ) + .first(); + await expect( item ).toBeVisible(); + await caption( + page, + 'The block is in the beehiiv category of the inserter (AC-001)', + 2500 + ); + await glideClick( page, item ); + const divider = canvas.locator( + '[data-type="beehiiv/gated-content-divider"]' + ); + await expect( divider ).toBeVisible( { timeout: 15000 } ); + await caption( + page, + 'A new block starts with a paragraph, a divider, and a paragraph. Move the divider like any block (AC-005)', + 4500 + ); + const gated = canvas.locator( '[data-type="beehiiv/gated-content"]' ); + const lastParagraph = gated + .locator( '[data-type="core/paragraph"]' ) + .last(); + await lastParagraph.click(); + await page.keyboard.type( + 'Tuesday to Friday: the full briefing, only for subscribers.' + ); + await caption( + page, + 'Above the divider is the preview. Below it is gated content (AC-009)', + 3500 + ); + await page.getByRole( 'button', { name: 'Close block inserter' } ).click(); + await gated.click( { position: { x: 4, y: 4 } } ); + const formControl = page.getByLabel( 'Subscribe form ID' ); + await expect( formControl ).toBeVisible(); + await point( page, formControl, 1200 ); + await caption( + page, + `The sidebar shows the default form ID from settings: ${ FORM_ID } (AC-032)`, + 4000 + ); + await expect( formControl ).toHaveAttribute( 'placeholder', FORM_ID ); + await expect( page.getByLabel( 'Headline' ) ).toBeVisible(); + await caption( + page, + 'Each block can override the headline and text, and choose the card colors', + 3500 + ); + await page.evaluate( async () => { + const { dispatch } = window.wp.data; + dispatch( 'core/editor' ).editPost( { status: 'publish' } ); + await dispatch( 'core/editor' ).savePost(); + } ); + await page.waitForFunction( + () => + window.wp.data.select( 'core/editor' ).getCurrentPost().status === + 'publish', + null, + { timeout: 20000 } + ); + await caption( page, 'Publish', 1500 ); + + // 3. Reader view: locked until the reader subscribes ----------------------- + await caption( page, '3/4 · A reader opens the post (logged out)', 1500 ); + await page.goto( '/wp-login.php?action=logout' ); + await page.getByRole( 'link', { name: 'log out' } ).click(); + await page.goto( `/?p=${ postId }` ); + await expect( page.getByText( 'Monday: the story opens' ) ).toBeVisible(); + await caption( page, 'The preview shows normally (AC-011)', 2500 ); + const gate = page.locator( '[data-beehiiv-gate]' ); + const locked = page.locator( '.beehiiv-gated-content__locked' ); + await expect( gate ).toBeVisible(); + await expect( locked ).toHaveAttribute( 'inert', '' ); + await point( page, page.locator( '.beehiiv-gated-content__card' ), 1500 ); + await caption( + page, + 'The gated content is blurred and faded, and the subscribe card sits over it (AC-012, AC-013)', + 4500 + ); + await caption( + page, + 'In production the beehiiv form appears in this card. The recording blocks beehiiv, so only the card is shown.', + 4500 + ); + await caption( + page, + 'The locked text cannot be selected, focused, or read by screen readers until it unlocks (AC-026)', + 4000 + ); + + // 4. Subscribe: unlock, remember, and skip for logged-in users -------------- + await caption( + page, + '4/4 · The reader subscribes. beehiiv\'s form sends a "submitted" message with its form ID (AC-016)', + 1500 + ); + await submitForm( page, 'some-other-form' ); + await page.waitForTimeout( 800 ); + await expect( locked ).toHaveAttribute( 'inert', '' ); + await caption( + page, + 'A different form on the page does not unlock it', + 3500 + ); + await submitForm( page, FORM_ID ); + await expect( locked ).not.toHaveAttribute( 'inert', /.*/ ); + await expect( page.getByText( 'Tuesday to Friday' ) ).toBeVisible(); + await caption( + page, + "This gate's own form unlocks the content in place, with no page reload (AC-018)", + 4000 + ); + const cookies = await page.context().cookies(); + expect( cookies.some( ( c ) => c.name === COOKIE ) ).toBe( true ); + await caption( + page, + 'One cookie remembers the reader for 30 days (AC-019)', + 3500 + ); + await page.reload(); + await expect( page.getByText( 'Tuesday to Friday' ) ).toBeVisible(); + await expect( page.locator( '.beehiiv-gated-content__card' ) ).toBeHidden(); + await caption( + page, + 'A returning reader sees the full post with no gate (AC-021)', + 4000 + ); + + await page.context().clearCookies(); + await page.goto( '/wp-login.php' ); + await kit.loginAsAdmin( page ); + await page.goto( `/?p=${ postId }` ); + await expect( page.getByText( 'Tuesday to Friday' ) ).toBeVisible(); + await expect( page.locator( '[data-beehiiv-gate]' ) ).toHaveCount( 0 ); + await caption( + page, + 'Logged-in users, such as editors and admins, always see the full post (AC-022)', + 4500 + ); + await caption( page, 'End of walkthrough', 2500 ); +} ); From f53af4579f4dd33e4dd6afbd048fa0775621856f Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 4 Nov 2026 20:08:51 +0800 Subject: [PATCH 7/7] test(e2e): add demo walkthrough for the Group section intake --- tests/e2e/demo/b4c73fce.demo.js | 294 ++++++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 tests/e2e/demo/b4c73fce.demo.js diff --git a/tests/e2e/demo/b4c73fce.demo.js b/tests/e2e/demo/b4c73fce.demo.js new file mode 100644 index 0000000..1e97eb0 --- /dev/null +++ b/tests/e2e/demo/b4c73fce.demo.js @@ -0,0 +1,294 @@ +// @demo-acs AC-009 AC-010 +/** + * One-take demo for intake b4c73fce: a WordPress Group block becomes a beehiiv + * section (Block Conversion Framework, US-006), and a Group inside a Group is + * left out and marked in the editor while the beehiiv sidebar is open. + * + * Run through the record-intake-demo skill (config: playwright.demo.config.js). + * Every action in the video goes through the real block editor. wp-cli only + * seeds data and reads back the request the plugin sent to beehiiv, which the + * tests environment mocks (tests/e2e/plugins/beehiiv-options.php). + */ +const path = require( 'path' ); +const os = require( 'os' ); +const fs = require( 'fs' ); +const kit = require( './kit' ); + +const { test, expect, wp, installOverlay, caption, glideClick, point } = kit; + +const PUB = 'pub_e2e_demo'; +const TPL = 'tpl_e2e_demo'; +const STATE = path.join( os.tmpdir(), 'demo-b4c73fce-auth.json' ); +const ids = []; + +const b64 = ( v ) => Buffer.from( v ).toString( 'base64' ); +const arg = ( v ) => + `json_decode( base64_decode( "${ b64( JSON.stringify( v ) ) }" ), true )`; +const php = ( code ) => + wp.wpCli( `eval 'eval( base64_decode( "${ b64( code ) }" ) );'` ); + +const para = ( t ) => + `

${ t }

`; +const head = ( t ) => + `

${ t }

`; +const group = ( inner ) => + `
${ inner }
`; + +/** + * Seeds a connected, ready site and an empty request log. + */ +function seed() { + php( ` + beehiiv_e2e_reset_all(); + beehiiv_e2e_seed_connection(); + beehiiv_e2e_mock_permissions( ${ arg( { posts: [ 'read', 'write' ] } ) } ); + update_option( "beehiiv_settings", ${ arg( { + publication_id: PUB, + post_template_id: TPL, + } ) } ); + beehiiv_e2e_seed_publications( ${ arg( [ + { id: PUB, name: 'Demo Publication' }, + ] ) } ); + beehiiv_e2e_seed_post_templates( "${ PUB }", ${ arg( [ + { id: TPL, name: 'Demo Template' }, + ] ) } ); + beehiiv_e2e_mock_http( "/posts", ${ arg( { + body: { data: { id: 'post_demo' } }, + } ) } ); + beehiiv_e2e_clear_http_log(); + ` ); +} + +/** + * Creates a draft that will send to the newsletter and returns its ID. + * + * @param {string} title Post title. + * @param {string} content Block markup. + * @return {number} Post ID. + */ +function createPost( title, content ) { + const out = php( ` + $id = wp_insert_post( ${ arg( { + post_type: 'post', + post_title: title, + post_content: content, + post_status: 'draft', + post_author: 1, + } ) } ); + update_post_meta( $id, "_beehiiv_send_to_newsletter", "1" ); + beehiiv_e2e_clear_http_log(); + echo "ID=" . $id; + ` ); + const id = Number( /ID=(\d+)/.exec( out )[ 1 ] ); + ids.push( id ); + return id; +} + +/** + * The blocks the plugin sent to beehiiv in its last create-post request. + * + * @return {Array} Blocks array. + */ +function sentBlocks() { + const out = php( 'beehiiv_e2e_print_http_log();' ); + const log = JSON.parse( out.slice( out.indexOf( '[' ) ) || '[]' ); + const create = log + .reverse() + .find( + ( r ) => + r.method === 'POST' && + /\/publications\/[^/]+\/posts$/.test( r.url ) + ); + expect( create, 'beehiiv create-post request was sent' ).toBeTruthy(); + return create.body.blocks || []; +} + +/** + * Publishes the open post from the editor, as a click on Publish would save it. + * + * @param {import('@playwright/test').Page} page Page. + */ +async function publish( page ) { + await page.evaluate( async () => { + await window.wp.data + .dispatch( 'core/editor' ) + .editPost( { status: 'publish' } ); + await window.wp.data.dispatch( 'core/editor' ).savePost(); + } ); + await page.waitForFunction( + () => + window.wp.data.select( 'core/editor' ).getCurrentPost().status === + 'publish', + null, + { timeout: 30000 } + ); +} + +/** + * Clicks the beehiiv button in the editor toolbar to open or close its sidebar. + * + * @param {import('@playwright/test').Page} page Page. + */ +async function toggleBeehiivSidebar( page ) { + await glideClick( + page, + page.getByRole( 'button', { name: 'beehiiv', exact: true } ).first() + ); +} + +/** + * Makes sure the beehiiv sidebar is closed, whatever state the editor remembered. + * + * @param {import('@playwright/test').Page} page Page. + */ +async function ensureSidebarClosed( page ) { + const content = page.locator( '.beehiiv-post-settings-content' ); + const open = await content + .waitFor( { state: 'visible', timeout: 3000 } ) + .then( () => true ) + .catch( () => false ); + if ( open ) { + await toggleBeehiivSidebar( page ); + } + await expect( content ).toBeHidden(); +} + +test.use( { storageState: STATE, viewport: { width: 1280, height: 720 } } ); + +test.beforeAll( async ( { browser } ) => { + wp.ensurePluginActive(); + wp.ensurePrettyPermalinks(); + seed(); + const ctx = await browser.newContext( { + storageState: undefined, + baseURL: process.env.WP_BASE_URL || 'http://localhost:8891', + } ); + const page = await ctx.newPage(); + await kit.loginAsAdmin( page ); + await ctx.storageState( { path: STATE } ); + await ctx.close(); +} ); + +test.afterAll( () => { + php( ` + foreach ( array_filter( explode( ",", "${ ids.join( + ',' + ) }" ) ) as $id ) { wp_delete_post( (int) $id, true ); } + beehiiv_e2e_reset_all(); + delete_option( "beehiiv_settings" ); + ` ); + fs.rmSync( STATE, { force: true } ); +} ); + +test( 'Group block to beehiiv section, with the omitted block marked in the beehiiv sidebar', async ( { + page, +} ) => { + await installOverlay( page ); + + const postId = createPost( + 'Group with a nested Group', + para( 'Intro paragraph outside the Group.' ) + + group( + head( 'Inside the Group' ) + + para( 'This paragraph goes into the section.' ) + + group( para( 'This Group sits inside the other Group.' ) ) + ) + ); + await kit.openPostEditor( page, postId ); + const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' ); + const marked = canvas.locator( '.beehiiv-omitted-block' ); + + await caption( + page, + 'WordPress Group block to beehiiv section · plugin build with the Group change', + 3500 + ); + await caption( + page, + 'A post with a Group. Inside it: a heading, a paragraph, and another Group', + 1200 + ); + await point( page, canvas.locator( '.wp-block-group' ).first(), 3000 ); + + // 1. Sidebar closed ------------------------------------------------------ + await ensureSidebarClosed( page ); + await expect( marked ).toHaveCount( 0 ); + await caption( + page, + 'The beehiiv panel is closed: the editor canvas shows no marks', + 3500 + ); + + // 2. Sidebar open -------------------------------------------------------- + await caption( page, 'Open the beehiiv panel', 1200 ); + await toggleBeehiivSidebar( page ); + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toContainText( '1 block will be omitted from this newsletter.' ); + await expect( marked ).toHaveCount( 1 ); + await expect( + canvas.locator( + '.wp-block-group .wp-block-group.beehiiv-omitted-block' + ) + ).toHaveCount( 1 ); + await point( + page, + page.locator( '.beehiiv-post-settings-notice__omitted' ), + 2500 + ); + await caption( + page, + 'The panel says 1 block will be omitted, and the inner Group shows the hidden-eye icon (AC-010)', + 5000 + ); + await point( page, marked, 2500 ); + await caption( + page, + 'A beehiiv section cannot hold another section, so the inner Group is left out', + 4500 + ); + + // 3. Toggle the panel off and on ---------------------------------------- + await caption( page, 'Close the panel: the marks go away', 1200 ); + await toggleBeehiivSidebar( page ); + await expect( marked ).toHaveCount( 0 ); + await caption( page, 'Marks hidden while the panel is closed', 3000 ); + await caption( page, 'Open it again: the marks come back', 1200 ); + await toggleBeehiivSidebar( page ); + await expect( marked ).toHaveCount( 1 ); + await caption( page, 'Marks are back with the panel open', 3000 ); + + // 4. Publish and check what beehiiv received ----------------------------- + await caption( + page, + 'Publish the post with "Send to newsletter" on', + 1500 + ); + await publish( page ); + + const sent = sentBlocks(); + expect( sent.map( ( b ) => b.type ) ).toEqual( [ 'paragraph', 'section' ] ); + const section = sent[ 1 ]; + expect( section.blocks.map( ( b ) => b.type ) ).toEqual( [ + 'heading', + 'paragraph', + ] ); + expect( JSON.stringify( sent ) ).not.toContain( + 'This Group sits inside the other Group.' + ); + await caption( + page, + 'beehiiv gets one section holding the heading and the paragraph (AC-009)', + 5000 + ); + await caption( + page, + 'The inner Group is not sent, so the post syncs without an error (AC-010)', + 5000 + ); + await caption( + page, + 'Group becomes a section. A Group inside a Group is marked, and left out', + 4500 + ); +} );