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 ); +} ); diff --git a/tests/e2e/demo/862095db.demo.js b/tests/e2e/demo/862095db.demo.js new file mode 100644 index 0000000..ece260f --- /dev/null +++ b/tests/e2e/demo/862095db.demo.js @@ -0,0 +1,440 @@ +// @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 = + '' + + 'Everything we shipped, in one place. This is the email your subscribers will receive, rendered by beehiiv with your template, header and footer.
' + + `` + + '' + + '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 + ); + + // 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(); + 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 ); +} ); 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 ) => + `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 {PromiseOur 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 checkThis 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/