`. |
-| `0a6bf62a6d9d6a04faf10e933d2362bbbdf3b5c8351c6452174f98bf29f723aa` | sha256 of the gallery helper | Body: [`tests/fixtures/capture-gallery-runtime.js`](../../tests/fixtures/capture-gallery-runtime.js), marker `data-blocks-engine-capture-gallery-runtime`, in ``. |
+| `b545f43a77b79d075373048431101fe11d7ff8cdfe7987de6d7971512763d88f` | sha256 of the disclosure interpreter | Body: [`tests/fixtures/capture-disclosure-runtime.js`](../../tests/fixtures/capture-disclosure-runtime.js), marker `data-blocks-engine-capture-disclosure-runtime` (value `true`), in ``. |
+| `456af76323601e51d851d3a4d6fe4ff6e3989c9969562f425fd72103c5c57c6b` | sha256 of the gallery helper | Body: [`tests/fixtures/capture-gallery-runtime.js`](../../tests/fixtures/capture-gallery-runtime.js), marker `data-blocks-engine-capture-gallery-runtime`, in ``. |
+
+Blocks Engine recognizes one body per runtime: the producer's current one.
+When the producer changes a body, Blocks Engine checks that its native
+replacement does what the new body does, then publishes the new body and
+digest in place of the old ones. An earlier body is no longer recognized: a
+page that carries it keeps its captured runtime wherever recognition is
+required (below).
+
+What the native replacement reproduces:
+
+- **Disclosure interpreter.** Opening and closing a dialog, and the ancestor
+ bindings in `data-blocks-engine-capture-dialog-ancestor-state`. A `class`
+ binding is replayed as tokens: tokens of the other state that the target
+ state lacks are removed, the target state's tokens are added, and every
+ other class on the ancestor is kept. The native dialog finds the ancestor by its tag and closed tokens.
+ `tests/unit/capture-runtime-ancestor-parity-browser.mjs` compares the two in
+ Chromium.
+- **Gallery helper.** Frame galleries become native carousels. A src-swap
+ gallery (`data-blocks-engine-capture-gallery-sequence` with
+ `data-blocks-engine-capture-gallery-slot` images) becomes a native src-swap
+ carousel. It starts at index 0, wraps in both directions, sets each slot to
+ the sequence image at the current index plus the slot's offset, and removes
+ the slot's `srcset`.
**How Blocks Engine verifies them.** For each page with captured dialog
scopes, `CapturedDialogProjector` scans every `'
+// The producer's published gallery runtime, which drives this src-swap shape.
+$portableGallerySource = ''
. '
'
. '
'
. ''
@@ -59,6 +60,10 @@
&& str_contains($portableGalleryMarkup, 'data-blocks-engine-capture-gallery-slot="1"'),
'portable gallery keeps its source three-slot layout, editable attachments, and functional controls without fallback blocks'
);
+$assert(
+ !str_contains($portableGalleryMarkup, 'data-blocks-engine-capture-gallery-direction') && true === ($portableGallery['attrs']['wrap'] ?? null) && 0 === ($portableGallery['attrs']['initialSlide'] ?? null),
+ 'native src-swap navigation owns the controls: it wraps from the captured index 0 like the gallery runtime, which no longer binds them'
+);
$compoundMenuResult = (new HtmlTransformer())->transform(
''
diff --git a/php-transformer/tests/unit/capture-runtime-ancestor-parity-browser.mjs b/php-transformer/tests/unit/capture-runtime-ancestor-parity-browser.mjs
new file mode 100644
index 000000000..263920dee
--- /dev/null
+++ b/php-transformer/tests/unit/capture-runtime-ancestor-parity-browser.mjs
@@ -0,0 +1,80 @@
+// Real-browser parity between the recognized capture disclosure runtime and
+// the native dialog that replaces it. Both pages get the same clicks; after
+// each one, every class token the capture recorded on each ancestor must be
+// present on the native page exactly when it is present on the source page.
+// Run `php tests/unit/capture-runtime-ancestor-parity.php` first for fixtures.
+import { readFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import assert from 'node:assert/strict';
+
+const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
+const fixture = JSON.parse(readFileSync(`${tmpdir()}/capture-runtime-ancestor-parity.json`, 'utf8'));
+const browser = await chromium.launch({ headless: true });
+let checks = 0;
+
+// The recorded tokens of each ancestor row plus the tokens the source node
+// starts with: a replay must change the recorded tokens and keep the rest.
+const rows = fixture.ancestors.map(row => ({
+ tag: row.tag,
+ tokens: [...new Set(`${row.closed.class} ${row.opened.class}`.split(/\s+/).filter(Boolean))],
+}));
+const snapshot = page => page.evaluate(rows => {
+ const trigger = document.querySelector('[aria-controls="site-menu-panel"]');
+ const control = trigger.matches('button') ? trigger : trigger.querySelector('button');
+ const ancestorTags = [];
+ for (let node = control.parentElement; node; node = node.parentElement) ancestorTags.push(node);
+ return {
+ expanded: control.getAttribute('aria-expanded'),
+ ancestors: rows.map(row => {
+ // Nearest ancestor with the tag whose classes hold the row's first
+ // identifying token (a token both states keep), else just the tag.
+ const node = ancestorTags.find(node => node.tagName.toLowerCase() === row.tag
+ && (row.tag !== 'div' || node.classList.contains('config-0')));
+ return Object.fromEntries(row.tokens.map(token => [token, node.classList.contains(token)]));
+ }),
+ };
+}, rows);
+
+// Read the authored classes before any runtime runs.
+const authored = await (await browser.newContext({ javaScriptEnabled: false })).newPage();
+await authored.setContent(fixture.source);
+const initial = await authored.evaluate(rows => rows.map(row => {
+ const trigger = document.querySelector('[aria-controls="site-menu-panel"]');
+ for (let node = trigger.parentElement; node; node = node.parentElement) {
+ if (node.tagName.toLowerCase() === row.tag && (row.tag !== 'div' || node.classList.contains('config-0'))) return [...node.classList];
+ }
+ return [];
+}), rows);
+rows.forEach((row, index) => { row.tokens = [...new Set([...row.tokens, ...initial[index]])]; });
+const source = await browser.newPage({ viewport: { width: 1280, height: 800 } });
+await source.setContent(fixture.source);
+const native = await browser.newPage({ viewport: { width: 1280, height: 800 } });
+await native.setContent(`${fixture.native}`);
+assert.equal(await native.locator('script[data-blocks-engine-capture-disclosure-runtime]').count(), 0, 'the native page carries no capture runtime');
+assert.equal(await source.locator('script[data-blocks-engine-capture-disclosure-runtime]').count(), 1, 'the source page runs the capture runtime');
+
+const steps = ['initial', 'open', 'close', 'reopen', 'Escape'];
+for (const step of steps) {
+ for (const page of [source, native]) {
+ if (step === 'Escape') await page.keyboard.press('Escape');
+ else if (step !== 'initial') await page.locator('[aria-controls="site-menu-panel"]').click();
+ await page.waitForTimeout(50);
+ }
+ const [sourceState, nativeState] = [await snapshot(source), await snapshot(native)];
+ assert.deepEqual(nativeState.ancestors, sourceState.ancestors, `${step}: native ancestor class tokens match the capture runtime`);
+ assert.equal(nativeState.expanded, sourceState.expanded, `${step}: native aria-expanded matches the capture runtime`);
+ checks += 2;
+}
+const opened = await (async () => {
+ await source.locator('[aria-controls="site-menu-panel"]').click();
+ return snapshot(source);
+})();
+assert.deepEqual(opened.ancestors, [
+ { 'dark-bold': false, header: true, 'theme-col--primary': true, light: true },
+ { 'config-0': true, 'config-1': true, 'config-2': true, 'menu-closed': false, 'menu-open': true, 'data-blocks-engine-capture-desktop-document': true, 'config-3': true },
+ { 'header--menu-open': true },
+], 'the fixture exercises re-ordered, token-only and empty-class bindings');
+checks++;
+
+await browser.close();
+console.log(`capture-runtime-ancestor-parity browser contract passed: ${checks} checks`);
diff --git a/php-transformer/tests/unit/capture-runtime-ancestor-parity.php b/php-transformer/tests/unit/capture-runtime-ancestor-parity.php
new file mode 100644
index 000000000..046a1d6fd
--- /dev/null
+++ b/php-transformer/tests/unit/capture-runtime-ancestor-parity.php
@@ -0,0 +1,95 @@
+ native dialog: ancestor class bindings.
+ *
+ * Blocks Engine retires the capture's disclosure runtime only when its body is
+ * the published one (CaptureEvidence::DISCLOSURE_RUNTIME_SHA256). The native
+ * dialog must then replay what that runtime replays. The current producer body
+ * replays ancestor `class` bindings as token sets (a re-ordered theme class, an
+ * empty closed class on `body`, a long class list recorded as changed tokens
+ * plus identifying ones). This test compiles a scoped dropdown whose trigger
+ * carries those bindings and checks that the published body is replaced and an
+ * altered body is kept. It writes the fixture for
+ * capture-runtime-ancestor-parity-browser.mjs, which compares the source
+ * runtime and the native dialog in Chromium.
+ */
+
+require dirname(__DIR__, 2) . '/vendor/autoload.php';
+
+use Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler\ArtifactCompiler;
+use Automattic\BlocksEngine\PhpTransformer\Contract\CaptureEvidence;
+use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\CapturedDialogBlockGenerator;
+
+$failures = 0;
+$assert = static function (bool $condition, string $message, string $context = '') use (&$failures): void {
+ if ($condition) return;
+ ++$failures;
+ fwrite(STDERR, "FAIL: {$message}\n" . ('' !== $context ? substr($context, 0, 1600) . "\n" : ''));
+};
+
+$runtime = trim((string) file_get_contents(dirname(__DIR__) . '/fixtures/capture-disclosure-runtime.js'));
+$assert(CaptureEvidence::DISCLOSURE_RUNTIME_SHA256 === hash('sha256', $runtime), 'the fixture is the published disclosure runtime');
+
+// Rows as the producer records them: a re-ordered theme class on the header,
+// an empty closed class on body, and a class list recorded as tokens only.
+$ancestors = array(
+ array('selector' => '#site-header', 'tag' => 'header', 'depth' => 2, 'closed' => array('class' => 'dark-bold header theme-col--primary'), 'opened' => array('class' => 'header theme-col--primary light')),
+ array('selector' => '#scope', 'tag' => 'div', 'depth' => 3, 'closed' => array('class' => 'config-0 config-1 config-2 menu-closed'), 'opened' => array('class' => 'config-0 config-1 config-2 menu-open'), 'classTokensOnly' => true),
+ array('selector' => 'body', 'tag' => 'body', 'depth' => 4, 'closed' => array('class' => ''), 'opened' => array('class' => 'header--menu-open')),
+);
+$key = 'site-menu-panel';
+$trigger = '';
+$panel = '