diff --git a/dev/test/fixtures/tabs.intent.json b/dev/test/fixtures/tabs.intent.json new file mode 100644 index 0000000..20c7541 --- /dev/null +++ b/dev/test/fixtures/tabs.intent.json @@ -0,0 +1,54 @@ +{ + "blocks": [ + { + "block": "core/tabs", + "attrs": { + "activeTabIndex": 0 + }, + "children": [ + { + "block": "core/tab-list", + "attrs": { + "tabs": [ + { + "label": "Overview" + }, + { + "label": "Details" + } + ] + } + }, + { + "block": "core/tab-panels", + "children": [ + { + "block": "core/tab-panel", + "attrs": { + "label": "Overview" + }, + "children": [ + { + "block": "core/paragraph", + "text": "First panel content." + } + ] + }, + { + "block": "core/tab-panel", + "attrs": { + "label": "Details" + }, + "children": [ + { + "block": "core/paragraph", + "text": "Second panel content." + } + ] + } + ] + } + ] + } + ] +} diff --git a/dev/test/intent-tabs.test.ts b/dev/test/intent-tabs.test.ts new file mode 100644 index 0000000..05b81a9 --- /dev/null +++ b/dev/test/intent-tabs.test.ts @@ -0,0 +1,50 @@ +import { readFile } from 'node:fs/promises'; +import { describe, expect, it } from 'vitest'; +import { realize, validate } from '../../src/index.js'; +import { getWp } from '../../src/headless/wp.js'; +import type { IntentNode, IntentTree, WpBlock } from '../../src/types.js'; + +const shape = (block: WpBlock): unknown => [block.name, block.innerBlocks.map(shape)]; +const intentShape = (node: IntentNode): unknown => [node.block, (node.children ?? []).map(intentShape)]; + +for (const variant of ['plain', 'rich', 'independent'] as const) { + describe(`native Tabs: ${variant}`, () => { + it('preserves nesting, labels, content and active index through the shared gate', async () => { + const tree = JSON.parse(await readFile(new URL('./fixtures/tabs.intent.json', import.meta.url), 'utf8')) as IntentTree; + if (variant === 'rich') { + tree.blocks[0]!.children![1]!.children![1]!.children!.push( + { block: 'core/heading', text: 'Panel heading' }, + { block: 'core/list', items: ['First item', 'Second item'] }, + { block: 'core/buttons', children: [{ block: 'core/button', text: 'Read more', url: 'https://example.org/details' }] }, + ); + } + if (variant === 'independent') { + const second = structuredClone(tree.blocks[0]!); + second.attrs = { activeTabIndex: 1 }; + tree.blocks.push(second); + } + const report = await realize(JSON.stringify(tree)); + expect(report.ok, JSON.stringify(report.items)).toBe(true); + expect(report.summary).toMatchObject({ invalid: 0, warnings: 0 }); + expect(report.output).not.toMatch(/wp:(?:html|freeform)/); + const wp = await getWp(); + const parsed = wp.parse(report.output!); + // List shorthand expands into native list items. + if (variant !== 'rich') expect(parsed.map(shape)).toEqual(tree.blocks.map(intentShape)); + for (const [index, tabs] of parsed.entries()) { + expect(tabs.attributes.activeTabIndex).toBe(index === 1 ? 1 : 0); + expect(tabs.innerBlocks.map((block) => block.name)).toEqual(['core/tab-list', 'core/tab-panels']); + expect(JSON.parse(JSON.stringify(tabs.innerBlocks[0]!.attributes.tabs))).toEqual([{ label: 'Overview' }, { label: 'Details' }]); + expect(tabs.innerBlocks[1]!.innerBlocks.map((block) => block.attributes.label)).toEqual(['Overview', 'Details']); + } + expect(report.output).toContain('First panel content.'); + expect(report.output).toContain('Second panel content.'); + if (variant === 'rich') { + for (const value of ['Panel heading', 'First item', 'Second item', 'Read more', 'https://example.org/details']) expect(report.output).toContain(value); + expect(parsed[0]!.innerBlocks[1]!.innerBlocks[1]!.innerBlocks.map((block) => block.name)).toEqual(['core/paragraph', 'core/heading', 'core/list', 'core/buttons']); + } + expect((await validate(report.output!)).ok).toBe(true); + expect(wp.serialize(parsed)).toBe(report.output); + }); + }); +} diff --git a/dev/test/proof-tabs-wordpress.test.ts b/dev/test/proof-tabs-wordpress.test.ts new file mode 100644 index 0000000..3044402 --- /dev/null +++ b/dev/test/proof-tabs-wordpress.test.ts @@ -0,0 +1,161 @@ +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; +import { startWithPreparedStageMount } from '../../scripts/proof-control-startup.mjs'; +import { createWordPressProofLifecycle } from './proof-wordpress-lifecycle.js'; +import { realize, validate } from '../../src/index.js'; + +const execFileAsync = promisify(execFile); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const wpEnvConfig = path.join(root, 'proof/wp-env.json'); +const tabsFixture = path.join(root, 'dev/test/fixtures/tabs.intent.json'); +const tabsBrowser = path.join(root, 'scripts/proof-tabs-playwright.mjs'); +type CommandEvidence = { command: string; args: string[]; exitCode: number; stdout: string; stderr: string }; + +/** This explicit suite is the native Tabs runtime receipt, not a plugin proof profile. */ +describe('native Tabs in WordPress 7.1', () => { + it('preserves labels and content through native editing and independent frontend interaction', async () => { + const outputDir = await tabsProofOutputDirectory(); + const token = `block-runner-tabs-${Date.now()}`; + const lifecycle = createWordPressProofLifecycle(async (args) => { + const result = await wpEnv([...args], commands); + if (result.exitCode) throw new Error(result.stderr || result.stdout); + if (args[0] === 'status') alreadyRunning = JSON.parse(result.stdout).status === 'running'; + return result; + }); + let alreadyRunning = false; + let dockerBlocked = false; + let failure: unknown; + const commands: CommandEvidence[] = []; + try { + try { + await requireDocker(commands); + } catch (error) { + dockerBlocked = true; + throw error; + } + await lifecycle.prepare(); + if (!alreadyRunning) { + const started = await startWithPreparedStageMount({ root, start: () => wpEnv(['start'], commands) }); + if (started.exitCode) throw new Error(started.stderr || started.stdout); + } + // wp-env commands share mutable environment state, so concurrent CLI + // invocations can make one lose the active environment mid-proof. + const wordpressVersion = (await wp(['core', 'version'], commands)).stdout.trim(); + const phpVersion = (await wp(['eval', 'echo PHP_VERSION;'], commands)).stdout.trim(); + expect(wordpressVersion).toMatch(/^7\.1(?:\.\d+)?$/); + expect(phpVersion).toMatch(/^8\.3(?:\.\d+)?$/); + + const fixture = JSON.parse(await readFile(tabsFixture, 'utf8')); + const second = structuredClone(fixture.blocks[0]); + second.attrs.activeTabIndex = 1; + second.children[1].children[1].children.push( + { block: 'core/heading', text: 'Rich panel heading' }, + { block: 'core/list', items: ['First item', 'Second item'] }, + ); + fixture.blocks.push(second); + const assembled = await realize(JSON.stringify(fixture)); + expect(assembled.ok, JSON.stringify(assembled.items)).toBe(true); + expect((await validate(assembled.output ?? '')).ok).toBe(true); + const browserConfig = path.join(outputDir, 'tabs-proof.json'); + const browserOutput = path.join(outputDir, 'tabs-proof-result.json'); + await writeFile(browserConfig, `${JSON.stringify({ + baseUrl: 'http://localhost:8888', runtime: { wordpressVersion, phpVersion }, + fixture: JSON.stringify(fixture), markup: assembled.output, postTitle: `${token} proof`, + }, null, 2)}\n`, 'utf8'); + const browser = await execFileAsync(process.execPath, [tabsBrowser, '--config', browserConfig, '--out', browserOutput], { + cwd: root, + timeout: 180_000, + }).then(() => ({ exitCode: 0, stdout: '', stderr: '' }), (error: NodeJS.ErrnoException & { stdout?: string; stderr?: string }) => ({ + exitCode: 1, stdout: error.stdout ?? '', stderr: error.stderr || error.message, + })); + commands.push({ command: process.execPath, args: [tabsBrowser, '--config', browserConfig, '--out', browserOutput], exitCode: browser.exitCode, stdout: browser.stdout, stderr: browser.stderr }); + const evidence = existsSync(browserOutput) ? JSON.parse(await readFile(browserOutput, 'utf8')) : undefined; + process.stderr.write(`${JSON.stringify({ tabsProofEvidence: outputDir, browserExitCode: browser.exitCode, errors: evidence?.errors ?? [] })}\n`); + expect(browser.exitCode, JSON.stringify(evidence, null, 2)).toBe(0); + expect(evidence?.errors).toEqual([]); + expect(evidence?.editor?.reopened).toMatchObject({ isDirty: false, invalidBlocks: [] }); + expect(evidence?.frontend?.beforeEditor).toBeDefined(); + expect(evidence?.frontend?.nativeControl).toBeDefined(); + expect(evidence?.editor?.editedReopened).toMatchObject({ isDirty: false, invalidBlocks: [] }); + } catch (error) { + failure = error; + throw error; + } finally { + await lifecycle.cleanup().catch(() => undefined); + try { + await writeFile(path.join(outputDir, 'commands.json'), `${JSON.stringify(commands, null, 2)}\n`, 'utf8'); + if (failure) { + await writeFile(path.join(outputDir, 'failure.json'), `${JSON.stringify({ + status: 'failed', + reason: failure instanceof Error ? failure.message : String(failure), + }, null, 2)}\n`, 'utf8'); + if (dockerBlocked) { + await writeFile(path.join(outputDir, 'runtime-blocked.json'), `${JSON.stringify({ + status: 'blocked', + prerequisite: 'docker', + reason: failure instanceof Error ? failure.message : String(failure), + }, null, 2)}\n`, 'utf8'); + } + } + } catch (retentionError) { + // Never replace the runtime failure with an evidence-write failure. + if (failure) { + process.stderr.write(`${JSON.stringify({ tabsProofEvidence: outputDir, retentionError: retentionError instanceof Error ? retentionError.message : String(retentionError) })}\n`); + } else { + throw retentionError; + } + } + process.stderr.write(`${JSON.stringify({ + tabsProofEvidence: outputDir, + failure: failure instanceof Error ? failure.message : failure === undefined ? undefined : String(failure), + })}\n`); + } + }, 300_000); +}); + +async function wp(args: string[], commands: CommandEvidence[]) { + const result = await wpEnv(['run', 'cli', 'wp', ...args], commands); + if (result.exitCode !== 0) throw new Error(`wp ${args.join(' ')} failed: ${result.stderr || result.stdout}`); + return result; +} + +async function wpEnv(args: string[], commands: CommandEvidence[]) { + try { + const { stdout, stderr } = await execFileAsync('npx', ['--no-install', 'wp-env', `--config=${wpEnvConfig}`, ...args], { cwd: root, timeout: args[0] === 'start' ? 180_000 : 45_000 }); + const result = { command: 'npx', args: ['--no-install', 'wp-env', `--config=${wpEnvConfig}`, ...args], exitCode: 0, stdout, stderr }; + commands.push(result); + return result; + } catch (error) { + const failure = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string }; + const result = { command: 'npx', args: ['--no-install', 'wp-env', `--config=${wpEnvConfig}`, ...args], exitCode: 1, stdout: failure.stdout ?? '', stderr: `${failure.message}\n${failure.stderr ?? ''}` }; + commands.push(result); + return result; + } +} + +/** CI supplies a retained root; local runs remain isolated in a temporary directory. */ +async function tabsProofOutputDirectory(): Promise { + const configured = process.env.BLOCK_RUNNER_PROOF_OUTPUT_DIR; + if (!configured) return mkdtemp(path.join(tmpdir(), 'block-runner-tabs-proof-')); + const outputDir = path.resolve(configured, 'tabs'); + await mkdir(outputDir, { recursive: true }); + return outputDir; +} + +async function requireDocker(commands: CommandEvidence[]) { + try { + const args = ['info', '--format', '{{.ServerVersion}}']; + const { stdout, stderr } = await execFileAsync('docker', args, { timeout: 15_000 }); + commands.push({ command: 'docker', args, exitCode: 0, stdout, stderr }); + } catch (error) { + const failure = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string }; + commands.push({ command: 'docker', args: ['info', '--format', '{{.ServerVersion}}'], exitCode: 1, stdout: failure.stdout ?? '', stderr: `${failure.message}\n${failure.stderr ?? ''}` }); + throw new Error(`The native Tabs proof requires a working Docker CLI and daemon: ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/scripts/proof-tabs-playwright.mjs b/scripts/proof-tabs-playwright.mjs new file mode 100644 index 0000000..7e9eba2 --- /dev/null +++ b/scripts/proof-tabs-playwright.mjs @@ -0,0 +1,204 @@ +#!/usr/bin/env node +// Native page-content proof, deliberately separate from generated-plugin acceptance. +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { chromium, expect } from '@playwright/test'; + +const args = process.argv.slice(2); +const config = JSON.parse(await readFile(args[args.indexOf('--config') + 1], 'utf8')); +const output = args[args.indexOf('--out') + 1]; +const artifactDir = path.join(path.dirname(output), 'artifacts'); +await mkdir(artifactDir, { recursive: true }); +const hash = (text) => `sha256:${createHash('sha256').update(text).digest('hex')}`; +const result = { fixtureHash: hash(config.fixture), outputHash: hash(config.markup), runtime: config.runtime, editor: {}, frontend: {}, errors: [] }; +let browser, page; +try { + browser = await chromium.launch({ headless: true }); + result.runtime.browser = browser.version(); + page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + page.setDefaultTimeout(20_000); + page.on('pageerror', (error) => result.errors.push(error.message)); + await login(page, config.baseUrl); + // Load the native editor scripts before using its authenticated REST client. + await page.goto(`${config.baseUrl}/wp-admin/post-new.php`, { waitUntil: 'load' }); + await waitForEditorReady(page); + const created = await page.evaluate(async ({ markup, title }) => wp.apiFetch({ + path: '/wp/v2/posts', method: 'POST', data: { title, content: markup, status: 'publish' }, + }), { markup: config.markup, title: config.postTitle }); + const postId = created.id; + assert.ok(Number.isInteger(postId) && postId > 0); + // No editor has opened this post: buttons must already exist in assembled markup. + result.frontend.beforeEditor = await frontend(created.link, ['Overview', 'Details']); + await openEditor(postId); + result.editor.beforeSave = await editorState(); + checkState(result.editor.beforeSave, ['Overview', 'Details']); + await editPostTitle(page, `${config.postTitle} saved`); + await savePost(page, postId); + result.editor.saved = await editorState(); + await openEditor(postId); + result.editor.reopened = await editorState(); + checkReopened(result.editor.saved, result.editor.reopened, ['Overview', 'Details']); + const canvas = await page.locator('iframe[name="editor-canvas"]').count() + ? page.frameLocator('iframe[name="editor-canvas"]') : page; + const first = canvas.locator('[data-type="core/tabs"]').first(); + await first.locator('[role="tab"] [contenteditable="true"]').first().fill('Updated overview'); + await first.locator('[data-type="core/paragraph"][contenteditable="true"]').first().fill('Edited first panel content.'); + await page.waitForFunction(() => { + const tabs = wp.data.select('core/block-editor').getBlocks()[0]; + return String(tabs.innerBlocks[0].attributes.tabs[0].label) === 'Updated overview'; + }); + await savePost(page, postId); + result.editor.editedSaved = await editorState(); + await openEditor(postId); + result.editor.editedReopened = await editorState(); + checkReopened(result.editor.editedSaved, result.editor.editedReopened, ['Updated overview', 'Details']); + assert.ok(result.editor.editedReopened.content.includes('Edited first panel content.')); + result.frontend.afterEditing = await frontend(created.link, ['Updated overview', 'Details']); + await expect(page.locator('.wp-block-tabs').first()).toContainText('Edited first panel content.'); + + // Native WordPress createBlock/serialize control bypasses Block Runner entirely. + await page.goto(`${config.baseUrl}/wp-admin/post-new.php`, { waitUntil: 'load' }); + await waitForEditorReady(page); + const control = await page.evaluate(async (title) => { + const { createBlock: block, serialize } = wp.blocks; + const tabs = (activeTabIndex) => block('core/tabs', { activeTabIndex }, [ + block('core/tab-list', { tabs: [{ label: 'Overview' }, { label: 'Details' }] }), + block('core/tab-panels', {}, ['Overview', 'Details'].map((label) => block('core/tab-panel', { label }, [block('core/paragraph', { content: `${label} native content.` })]))), + ]); + return wp.apiFetch({ path: '/wp/v2/posts', method: 'POST', data: { title: `${title} native control`, content: serialize([tabs(0), tabs(1)]), status: 'publish' } }); + }, config.postTitle); + result.frontend.nativeControl = await frontend(control.link, ['Overview', 'Details']); + assert.deepEqual(result.frontend.beforeEditor.keyboard, result.frontend.nativeControl.keyboard); +} catch (error) { + result.errors.push(error.stack ?? String(error)); + if (page) await page.screenshot({ path: path.join(artifactDir, 'failure.png'), fullPage: true }).catch(() => {}); +} finally { + if (page) await writeFile(path.join(artifactDir, 'final.html'), await page.content().catch(() => '')); + if (browser) await browser.close(); + await writeFile(output, `${JSON.stringify(result, null, 2)}\n`); +} +if (result.errors.length) process.exitCode = 1; + +async function openEditor(id) { + await page.goto(`${config.baseUrl}/wp-admin/post.php?post=${id}&action=edit`, { waitUntil: 'load' }); + await waitForEditorReady(page); +} +async function editorState() { + const state = await page.evaluate(() => { + const blocks = wp.data.select('core/block-editor').getBlocks(); + const flatten = (nodes) => nodes.flatMap((node) => [node, ...flatten(node.innerBlocks)]); + const editor = wp.data.select('core/editor'); + return { + content: editor.getEditedPostContent(), isDirty: editor.isEditedPostDirty(), + invalidBlocks: flatten(blocks).filter((node) => node.isValid === false).map((node) => node.name), + tabs: blocks.map((tabs) => ({ name: tabs.name, activeTabIndex: tabs.attributes.activeTabIndex, + children: tabs.innerBlocks.map((node) => node.name), + labels: tabs.innerBlocks[0].attributes.tabs.map((tab) => String(tab.label)), + panels: tabs.innerBlocks[1].innerBlocks.map((panel) => ({ name: panel.name, label: panel.attributes.label, children: panel.innerBlocks.map((node) => node.name) })), + })), + }; + }); + return { ...state, contentHash: hash(state.content) }; +} +function checkState(state, labels) { + assert.deepEqual(state.invalidBlocks, []); + assert.equal(state.tabs.length, 2); + for (const [i, tabs] of state.tabs.entries()) { + assert.equal(tabs.name, 'core/tabs'); + assert.equal(tabs.activeTabIndex, i); + assert.deepEqual(tabs.children, ['core/tab-list', 'core/tab-panels']); + assert.deepEqual(tabs.labels, i === 0 ? labels : ['Overview', 'Details']); + assert.deepEqual(tabs.panels.map((panel) => panel.label), tabs.labels); + assert.ok(tabs.panels.every((panel) => panel.name === 'core/tab-panel' && panel.children.includes('core/paragraph'))); + } +} +function checkReopened(saved, reopened, labels) { + checkState(reopened, labels); + assert.equal(reopened.isDirty, false); + assert.equal(saved.contentHash, reopened.contentHash); +} +async function frontend(url, labels) { + const response = await page.goto(url, { waitUntil: 'networkidle' }); + assert.equal(response.status(), 200); + const groups = page.locator('.wp-block-tabs'); + await expect(groups).toHaveCount(2); + const tabs = groups.first().getByRole('tab'); + await expect(tabs).toHaveText(labels); + const secondTabs = groups.nth(1).getByRole('tab'); + await expect(secondTabs.nth(1)).toHaveAttribute('aria-selected', 'true'); + const associations = await groups.evaluateAll((nodes) => nodes.map((group) => [...group.querySelectorAll('[role="tab"]')].map((tab) => { + const panel = document.getElementById(tab.getAttribute('aria-controls')); + return { tab: tab.id, panel: panel?.id, label: panel?.getAttribute('aria-labelledby'), contained: group.contains(panel) }; + }))); + const ids = associations.flatMap((group) => group.flatMap((item) => [item.tab, item.panel])); + assert.equal(new Set(ids).size, ids.length); + assert.ok(associations.flat().every((item) => item.tab && item.panel && item.label === item.tab && item.contained)); + await tabs.nth(1).click(); + await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true'); + await expect(groups.first().locator('[role="tabpanel"]').nth(1)).not.toHaveAttribute('hidden'); + await expect(secondTabs.nth(1)).toHaveAttribute('aria-selected', 'true'); + await tabs.nth(0).click(); + const keyboard = []; + for (const key of ['ArrowRight', 'Enter', 'Home', ' ', 'End', 'ArrowRight', 'ArrowLeft']) { + await page.keyboard.press(key); + keyboard.push({ key, ...(await groups.first().evaluate((group) => { + const buttons = [...group.querySelectorAll('[role="tab"]')]; + return { focused: buttons.indexOf(document.activeElement), selected: buttons.findIndex((tab) => tab.getAttribute('aria-selected') === 'true') }; + })) }); + } + // Core uses manual activation: arrows move focus, Enter/Space activate. + assert.deepEqual(keyboard.slice(0, 4).map(({ focused, selected }) => [focused, selected]), [[1, 0], [1, 1], [0, 1], [0, 0]]); + await expect(secondTabs.nth(1)).toHaveAttribute('aria-selected', 'true'); + return { url, associations, keyboard }; +} +async function login(page, origin) { + await page.goto(`${origin}/wp-login.php`, { waitUntil: 'domcontentloaded' }); + if (!/wp-login\.php/.test(page.url())) return; + await page.waitForLoadState('load'); + // WordPress focuses the username field from its load handler. Wait for that + // handoff before filling either field, otherwise the late focus can put the + // password into the username input on a cold browser session. + await page.waitForFunction(() => document.activeElement?.id === 'user_login'); + await page.locator('#user_login').fill(process.env.WP_USERNAME ?? 'admin'); + await page.locator('#user_pass').fill(process.env.WP_PASSWORD ?? 'password'); + await Promise.all([ + page.waitForURL((url) => !url.pathname.endsWith('/wp-login.php'), { waitUntil: 'domcontentloaded' }), + page.locator('#wp-submit').click(), + ]); +} + +async function waitForEditorReady(page) { + await page.waitForFunction(() => Boolean(globalThis.wp?.data?.select('core/block-editor')?.getBlocks), undefined, { timeout: 20_000 }); + await page.locator('iframe[name="editor-canvas"], .block-editor-writing-flow, .editor-styles-wrapper').first() + .waitFor({ state: 'visible', timeout: 20_000 }); + await page.waitForFunction(() => globalThis.wp?.data?.select('core/editor')?.getCurrentPostId?.() > 0, undefined, { timeout: 20_000 }); + const welcome = page.getByRole('dialog', { name: /welcome to the editor/i }); + if (await welcome.isVisible().catch(() => false)) { + await welcome.getByRole('button', { name: /close/i }).click(); + } +} + +async function savePost(page, postId) { + const save = page.getByRole('region', { name: 'Editor top bar', exact: true }) + .getByRole('button', { name: /^(?:save draft|save|update)$/i }); + const [response] = await Promise.all([ + page.waitForResponse((response) => new URL(response.url()).pathname.endsWith(`/wp/v2/posts/${postId}`) + && response.request().method() === 'POST'), + save.click(), + ]); + if (!response.ok()) throw new Error(`WordPress editor save returned HTTP ${response.status()}.`); + await page.waitForFunction(() => { + const editor = globalThis.wp?.data?.select('core/editor'); + return !editor?.isSavingPost?.() && editor?.isEditedPostDirty?.() === false; + }, undefined, { timeout: 20_000 }); +} + +async function editPostTitle(page, value) { + const iframeTitle = page.frameLocator('iframe[name="editor-canvas"]') + .locator('.editor-post-title__input, [contenteditable="true"]').first(); + const topLevelTitle = page.locator('.editor-post-title__input, [contenteditable="true"]').first(); + const title = await iframeTitle.isVisible().catch(() => false) ? iframeTitle : topLevelTitle; + await title.fill(value); +} diff --git a/skills/block-runner/references/ASSEMBLE.md b/skills/block-runner/references/ASSEMBLE.md index 16d502f..38180c4 100644 --- a/skills/block-runner/references/ASSEMBLE.md +++ b/skills/block-runner/references/ASSEMBLE.md @@ -59,7 +59,8 @@ one fits. `core/quote`, `core/pullquote`, `core/details`, `core/gallery`, `core/table`, `core/code`, `core/separator`, `core/social-links`, `core/social-link`, `core/video`, `core/audio`, `core/embed`, `core/file`, `core/accordion`, `core/accordion-item`, -`core/accordion-heading`, `core/accordion-panel`. +`core/accordion-heading`, `core/accordion-panel`, `core/tabs`, `core/tab-list`, +`core/tab-panels`, `core/tab-panel`. ## Structural rules @@ -170,3 +171,81 @@ Use the project's actual taxonomy IDs and verify the result on the target site. assumed. It fails loudly rather than quietly: malformed JSON, or JSON with no blocks in it, exits `1` with a reason — it will never hand you a clean empty result. A block name that is not registered produces a warning naming that node. + +## Native Tabs page content + +Use `core/tabs > [core/tab-list, core/tab-panels > core/tab-panel]` for explicit +Tabs content on WordPress 7.1. Supply **both** `tab-list.attrs.tabs` and each +`tab-panel.attrs.label`, with matching labels in the same order. The saved buttons +come from the list array; the editor synchronises that array from panel labels. +Headless assembly does not run that editor effect. `text` is not a panel-label alias. + +```json +{ + "blocks": [ + { + "block": "core/tabs", + "attrs": { + "activeTabIndex": 0 + }, + "children": [ + { + "block": "core/tab-list", + "attrs": { + "tabs": [ + { + "label": "Overview" + }, + { + "label": "Details" + } + ] + } + }, + { + "block": "core/tab-panels", + "children": [ + { + "block": "core/tab-panel", + "attrs": { + "label": "Overview" + }, + "children": [ + { + "block": "core/paragraph", + "text": "First panel content." + } + ] + }, + { + "block": "core/tab-panel", + "attrs": { + "label": "Details" + }, + "children": [ + { + "block": "core/paragraph", + "text": "Second panel content." + } + ] + } + ] + } + ] + } + ] +} +``` + +The CLI `assemble` command and library `realize()` run the shared media/token/validity +gate. Library `assemble()` returns block objects without finalisation. Native Tabs +page-content proof covers plain and rich panels, two independent instances, frontend +buttons before opening the editor, label/body edits, save/reopen, and keyboard +focus versus activation against a native WordPress control. + +This does not infer Tabs from arbitrary HTML or qualify nested Tabs. Registered-block +composition and declared editable fields remain separate: a registered Tab Panel does +not grant an editable label field or a pattern-override contract in the authoring compiler. + +Sources: [WordPress 7.1 Tab List metadata](https://github.com/WordPress/WordPress/blob/b998fef9238af183f9523b3df71618e6e57498b6/wp-includes/blocks/tab-list/block.json) +and [native keyboard actions](https://github.com/WordPress/WordPress/blob/b998fef9238af183f9523b3df71618e6e57498b6/wp-includes/js/dist/script-modules/block-library/tabs/view.js). diff --git a/vitest.config.ts b/vitest.config.ts index d0b563b..3c3a938 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ exclude: [ 'dev/test/proof-real-wordpress.test.ts', 'dev/test/proof-query-wordpress.test.ts', + 'dev/test/proof-tabs-wordpress.test.ts', 'dev/test/plugin.consumer.test.ts', 'dev/test/proof-native-style-adapter-builder.test.ts', ], diff --git a/vitest.wordpress.config.ts b/vitest.wordpress.config.ts index a106053..3beb97d 100644 --- a/vitest.wordpress.config.ts +++ b/vitest.wordpress.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', - include: ['dev/test/proof-real-wordpress.test.ts', 'dev/test/proof-query-wordpress.test.ts'], + include: ['dev/test/proof-real-wordpress.test.ts', 'dev/test/proof-query-wordpress.test.ts', 'dev/test/proof-tabs-wordpress.test.ts'], fileParallelism: false, testTimeout: 480_000, hookTimeout: 60_000,