diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b6e48..b5d02ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 experience and the iframe embed do. Turn it off and the experience reports nothing to Ceros; the experience's analytics events still reach the page, so your own analytics keeps working. The block's editor preview, WordPress's post - preview and the Customizer never report, whatever the switch says. Inline and - server-rendered reporting needs an experience published after Ceros added it - to the manifest, and the account's Ceros Analytics setting on. + preview and the Customizer never report, whatever the switch says. Neither + does a server-rendered experience stored on your site (Store mode): the stored + copy is independent of Ceros, so the switch is off and can't be turned on until + you remove the stored copy. Inline and server-rendered reporting needs an + experience published after Ceros added it to the manifest, and the account's + Ceros Analytics setting on. ### Fixed - **The plugin reports an old plugin, not a bad API key.** The Ceros API rejects diff --git a/includes/flex-analytics.php b/includes/flex-analytics.php index 93f1189..b509eaa 100644 --- a/includes/flex-analytics.php +++ b/includes/flex-analytics.php @@ -17,6 +17,12 @@ * Previews never track: the editor's, WordPress's post preview and the * Customizer's. An author checking their own page is not an audience. * + * Store mode never tracks either. A stored copy doesn't include the reporting + * bundle, which the shell would still fetch from Ceros, and Ceros analytics + * events aren't versioned, so a stored runtime that falls behind could report + * events the pipeline no longer reads. Store mode is for a page that doesn't + * depend on Ceros, so it reports nothing. + * * @package ceros */ @@ -33,7 +39,7 @@ * @return bool True to report. */ function ceros_flex_tracking_enabled( $attributes, $is_preview ) { - if ( $is_preview ) { + if ( $is_preview || ceros_flex_is_store_mode( $attributes ) ) { return false; } @@ -43,6 +49,22 @@ function ceros_flex_tracking_enabled( $attributes, $is_preview ) { return false !== ( $attributes['cerosAnalytics'] ?? true ); } +/** + * Whether a block renders in SSR Store mode: SSR delivery from a copy stored on + * this site. A stored copy left behind after switching to another delivery mode + * doesn't count, because render.php only reads it for SSR. Mirrors + * isStoreMode() in src/ceros/constants.js. + * + * Applies whatever the block falls back to when the stored copy can't be read, + * so a Store-mode block never reports. + * + * @param array $attributes Block attributes. + * @return bool True in Store mode. + */ +function ceros_flex_is_store_mode( $attributes ) { + return 'ssr' === ( $attributes['deliveryMode'] ?? '' ) && ! empty( $attributes['storedIndexPath'] ); +} + /** * Whether a block embeds a Flex experience. Flex blocks carry a manifest URL, * except those saved before it was stored, which carry only the Inline snippet. diff --git a/src/ceros/components/sidebar-controls.js b/src/ceros/components/sidebar-controls.js index 78340be..b974e6e 100644 --- a/src/ceros/components/sidebar-controls.js +++ b/src/ceros/components/sidebar-controls.js @@ -18,6 +18,7 @@ import { ACTION_TYPES, EMBED_OPTIONS, DELIVERY_MODES, + isStoreMode, manifestUrlFromInline, } from '../constants'; import { StoreControls } from './store-controls'; @@ -58,6 +59,8 @@ export function SidebarControls( { // includes/flex-analytics.php, which applies the choice at render. Legacy // Studio embeds have no manifest and no way to switch Ceros analytics off. const isFlex = hasInline || Boolean( attributes?.manifestUrl ); + // SSR Store mode never reports to Ceros (see isStoreMode()). + const isStored = isStoreMode( attributes ); return ( setAttributes( { cerosAnalytics: value } ) } diff --git a/src/ceros/constants.js b/src/ceros/constants.js index 2046eba..2058541 100644 --- a/src/ceros/constants.js +++ b/src/ceros/constants.js @@ -21,6 +21,23 @@ export const DELIVERY_MODES = { SSR: 'ssr', }; +/** + * Whether a block renders in SSR Store mode: SSR delivery from a copy stored on + * the site. A stored copy left behind after switching to another delivery mode + * doesn't count, because the server only reads it for SSR. Mirrors + * `ceros_flex_is_store_mode()` in includes/flex-analytics.php, which keeps + * Ceros analytics off in Store mode. + * + * @param {Object} attributes Block attributes. + * @return {boolean} True in Store mode. + */ +export function isStoreMode( attributes ) { + return ( + attributes?.deliveryMode === DELIVERY_MODES.SSR && + Boolean( attributes?.storedIndexPath ) + ); +} + /** * Undo the entity encoding wp_kses applies to attribute values, which rewrites a * bare `&` as `&`. Parsing in a detached textarea keeps the content as raw diff --git a/tests/e2e/tests/analytics-tracking.spec.ts b/tests/e2e/tests/analytics-tracking.spec.ts index 9684b5d..6b769bf 100644 --- a/tests/e2e/tests/analytics-tracking.spec.ts +++ b/tests/e2e/tests/analytics-tracking.spec.ts @@ -71,6 +71,26 @@ test.describe('Ceros analytics tracking', { tag: [TAGS.cerosBlock, TAGS.rendered await expect(published.iframeEmbed).toHaveAttribute(ANALYTICS, 'disabled') }) }) + + test.describe('in SSR Store mode, with the switch on', () => { + test.use({ + postOptions: { + title: 'flex analytics store mode', + blocks: [ + unfetchableFlexBlock(cerosFlexExperienceUrl(), { cerosAnalytics: true, stored: true }), + ], + status: 'publish', + }, + }) + + // A stored copy has no reporting bundle, so Store mode never reports, even + // when the stored copy can't be read and the block falls back. + test('does not report', async ({ page, post }) => { + const published = new PublishedPostPage(page) + await published.open(post.id) + await expect(published.iframeEmbed).toHaveAttribute(ANALYTICS, 'disabled') + }) + }) }) test.describe('in a preview, with the switch at its default', () => { diff --git a/tests/e2e/utils/block-serializer.ts b/tests/e2e/utils/block-serializer.ts index 07340db..e852347 100644 --- a/tests/e2e/utils/block-serializer.ts +++ b/tests/e2e/utils/block-serializer.ts @@ -11,6 +11,7 @@ export type CerosBlockAttributes = { fullHeightEmbedCode: string scrollableEmbedCode: string storedAt: string + storedIndexPath: string storedPublishedAt: string inlineEmbedCode: string cerosAnalytics: boolean @@ -54,15 +55,20 @@ export const resolvedFlexBlock = ( * stored iframe embed code and stamps the block's tracking choice onto it. The * manifest URL is http, which the fetch rejects before making a request, so the * render needs no network. `withManifestUrl: false` models a block saved before - * the manifest URL was stored, which carries only the Inline snippet. + * the manifest URL was stored, which carries only the Inline snippet. `stored` + * puts the block in SSR Store mode with a stored copy that isn't on disk, so the + * render still falls through to the stored embed code. */ export const unfetchableFlexBlock = ( experienceUrl: string, - options: { cerosAnalytics?: boolean; withManifestUrl?: boolean } = {}, + options: { cerosAnalytics?: boolean; withManifestUrl?: boolean; stored?: boolean } = {}, ): string => { const manifestUrl = `${experienceUrl.replace(/^https:/, 'http:')}/manifest.v1.json` return cerosBlock({ ...(options.withManifestUrl === false ? {} : { manifestUrl }), + ...(options.stored + ? { deliveryMode: 'ssr', storedIndexPath: 'ceros-flex/0/not-stored/v1/index.json' } + : {}), inlineEmbedCode: `
`, fullHeightEmbedCode: `
`, ...(options.cerosAnalytics === undefined ? {} : { cerosAnalytics: options.cerosAnalytics }), diff --git a/tests/js/constants.test.js b/tests/js/constants.test.js index 95fdd4f..122c287 100644 --- a/tests/js/constants.test.js +++ b/tests/js/constants.test.js @@ -15,6 +15,7 @@ import { describe, it, expect } from 'vitest'; import { manifestUrlFromInline, + isStoreMode, EMBED_OPTIONS, DELIVERY_MODES, } from '../../src/ceros/constants'; @@ -107,3 +108,50 @@ describe( 'shared constant values', () => { } ); } ); } ); + +/** + * Store mode keeps Ceros analytics off, so the sidebar and render.php + * (`ceros_flex_is_store_mode()`) must agree on what counts as Store mode. + */ +describe( 'isStoreMode', () => { + const storedIndexPath = 'ceros-flex/12/exp/v1/index.json'; + + it( 'is SSR rendering from a stored copy', () => { + expect( + isStoreMode( { deliveryMode: DELIVERY_MODES.SSR, storedIndexPath } ) + ).toBe( true ); + } ); + + it( 'is not live SSR', () => { + expect( isStoreMode( { deliveryMode: DELIVERY_MODES.SSR } ) ).toBe( + false + ); + expect( + isStoreMode( { + deliveryMode: DELIVERY_MODES.SSR, + storedIndexPath: '', + } ) + ).toBe( false ); + } ); + + it( 'ignores a stored copy left behind by another delivery mode', () => { + expect( + isStoreMode( { + deliveryMode: DELIVERY_MODES.INLINE, + storedIndexPath, + } ) + ).toBe( false ); + expect( + isStoreMode( { + deliveryMode: DELIVERY_MODES.IFRAME, + storedIndexPath, + } ) + ).toBe( false ); + expect( isStoreMode( { storedIndexPath } ) ).toBe( false ); + } ); + + it( 'is false with no attributes', () => { + expect( isStoreMode( undefined ) ).toBe( false ); + expect( isStoreMode( {} ) ).toBe( false ); + } ); +} ); diff --git a/tests/unit/FlexAnalyticsTest.php b/tests/unit/FlexAnalyticsTest.php index 98de88b..9be0e2e 100644 --- a/tests/unit/FlexAnalyticsTest.php +++ b/tests/unit/FlexAnalyticsTest.php @@ -84,6 +84,71 @@ public function test_never_tracks_a_preview( $attributes ) { $this->assertFalse( ceros_flex_tracking_enabled( $attributes, true ) ); } + /** + * @return array + */ + public static function store_mode_blocks() { + return [ + 'default' => [ self::stored() ], + 'toggle on' => [ self::stored( [ 'cerosAnalytics' => true ] ) ], + 'toggle off' => [ self::stored( [ 'cerosAnalytics' => false ] ) ], + ]; + } + + /** + * An SSR block rendering from a stored copy. + * + * @param array $attributes Attributes to add. + * @return array + */ + private static function stored( $attributes = [] ) { + return array_merge( + [ + 'deliveryMode' => 'ssr', + 'storedIndexPath' => 'ceros-flex/12/exp/v1/index.json', + ], + $attributes + ); + } + + /** + * A stored copy has no reporting bundle, and analytics events aren't + * versioned, so Store mode never reports. + * + * @dataProvider store_mode_blocks + */ + public function test_never_tracks_in_store_mode( $attributes ) { + $this->assertFalse( ceros_flex_tracking_enabled( $attributes, false ) ); + } + + /** + * @return array + */ + public static function store_mode_cases() { + return [ + 'ssr with a stored copy' => [ self::stored(), true ], + 'live ssr' => [ [ 'deliveryMode' => 'ssr' ], false ], + 'ssr with an empty path' => [ self::stored( [ 'storedIndexPath' => '' ] ), false ], + 'inline, stored copy left' => [ self::stored( [ 'deliveryMode' => 'inline' ] ), false ], + 'iframe, stored copy left' => [ self::stored( [ 'deliveryMode' => 'iframe' ] ), false ], + 'no delivery mode, stored' => [ [ 'storedIndexPath' => 'ceros-flex/12/exp/v1/index.json' ], false ], + ]; + } + + /** + * A stored copy left on a block that has switched away from SSR is never + * rendered, so it doesn't turn tracking off. + * + * @dataProvider store_mode_cases + */ + public function test_detects_store_mode( $attributes, $expected ) { + $this->assertSame( $expected, ceros_flex_is_store_mode( $attributes ) ); + } + + public function test_tracks_after_switching_away_from_store_mode() { + $this->assertTrue( ceros_flex_tracking_enabled( self::stored( [ 'deliveryMode' => 'inline' ] ), false ) ); + } + public function test_writes_enabled_when_tracking() { $this->assertSame( 'enabled', ceros_flex_analytics_value( true ) ); }