diff --git a/.themegrill-qa/suite.json b/.themegrill-qa/suite.json index 188a874..1ab5316 100644 --- a/.themegrill-qa/suite.json +++ b/.themegrill-qa/suite.json @@ -17,6 +17,11 @@ "demo": "@demo" }, "area_paths": { + "block-editor": [ + "inc/functions.php", + "style-editor-block.css", + "tests/e2e/utils/**" + ], "mobile-menu": [ "header.php", "js/navigation.js" diff --git a/inc/functions.php b/inc/functions.php index 608aad0..9c1ca44 100644 --- a/inc/functions.php +++ b/inc/functions.php @@ -123,12 +123,66 @@ function accelerate_scripts_styles_method() { } } +/** + * Enqueue the block editor styles and fonts, with the Customizer primary color. + * + * Hooked on `enqueue_block_assets` because styles enqueued on `enqueue_block_editor_assets` + * stay outside the editor iframe, or only reach it through a path WordPress warns about. + */ function accelerate_block_editor_styles() { - wp_enqueue_style( 'accelerate-editor-googlefonts', '//fonts.googleapis.com/css?family=Roboto:400,300,100|Roboto+Slab:700,400&display=swap' ); - wp_enqueue_style( 'accelerate-block-editor-styles', get_template_directory_uri() . '/style-editor-block.css' ); + if ( ! is_admin() ) { + return; + } + + // Same fonts as the front end. Google Fonts ignores the version, so none is added. + wp_enqueue_style( 'accelerate-editor-googlefonts', '//fonts.googleapis.com/css?family=Roboto:400,300,100|Roboto+Slab:700,400&display=swap', array(), null ); + // Theme version, so a theme update busts the cached editor stylesheet. + wp_enqueue_style( 'accelerate-block-editor-styles', get_template_directory_uri() . '/style-editor-block.css', array(), ACCELERATE_THEME_VERSION ); + + $editor_css = accelerate_block_editor_dynamic_css(); + + if ( $editor_css ) { + wp_add_inline_style( 'accelerate-block-editor-styles', $editor_css ); + } } -add_action( 'enqueue_block_editor_assets', 'accelerate_block_editor_styles', 1, 1 ); +add_action( 'enqueue_block_assets', 'accelerate_block_editor_styles' ); + +if ( ! function_exists( 'accelerate_block_editor_dynamic_css' ) ) : + + /** + * Build block editor CSS from the Customizer primary color. + * + * Mirrors the post content rules of accelerate_custom_css(), scoped to the editor canvas. + * Like the front end, the color outputs nothing while it is at its default. + * + * @return string Editor CSS. + */ + function accelerate_block_editor_dynamic_css() { + $wrapper = '.editor-styles-wrapper'; + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + $css = ''; + + $primary_color = accelerate_options( 'accelerate_primary_color', '#77CC6D' ); + + // Only a valid hex color reaches the CSS, so a stored value cannot break out of its rule. + $primary_color = '#77CC6D' !== (string) $primary_color ? sanitize_hex_color( $primary_color ) : ''; + + if ( $primary_color ) { + // The front end's `.wp-block-quote` rule beats the primary color, so only classic quotes follow it. + $css .= $wrapper . ' blockquote:not(.wp-block-quote), ' . $wrapper . ' input[type="reset"], ' . $wrapper . ' input[type="button"], ' . $wrapper . ' input[type="submit"], :where(' . $wrapper . ') button { background-color: ' . $primary_color . '; }'; + $css .= $wrapper . ' a { color: ' . $primary_color . '; }'; + } + + // The editor title is styled as a post title; a page title is an h1 on the front end. + if ( $screen && 'page' === $screen->post_type ) { + $css .= $wrapper . ' .editor-post-title__input { font-size: 30px; line-height: 36px; }'; + } + + return $css; + } + +endif; /****************************************************************************************/ diff --git a/style-editor-block.css b/style-editor-block.css index 6d130b0..2225ac0 100644 --- a/style-editor-block.css +++ b/style-editor-block.css @@ -11,10 +11,10 @@ .editor-styles-wrapper > * { color: #666666; font: 16px 'Roboto', sans-serif; + font-weight: 300; } -.editor-styles-wrapper .wp-block.editor-post-title__block .editor-post-title__input { - color: #444444; +.editor-styles-wrapper .editor-post-title__input { font-size: 26px; line-height: 32px; font-weight: normal; @@ -260,6 +260,17 @@ } /* Forms */ +/* Like the front end's body, button, input, select, textarea rule; the editor's own code fields keep their font. */ +.editor-styles-wrapper input, +.editor-styles-wrapper select, +.editor-styles-wrapper textarea:not(.block-editor-plain-text), +:where(.editor-styles-wrapper) button { + color: #666666; + font: 16px 'Roboto', sans-serif; + line-height: 20px; + font-weight: 300; +} + .editor-styles-wrapper input[type="text"], .editor-styles-wrapper input[type="email"], .editor-styles-wrapper input[type="search"], @@ -292,7 +303,8 @@ .editor-styles-wrapper input[type="reset"], .editor-styles-wrapper input[type="button"], -.editor-styles-wrapper input[type="submit"] { +.editor-styles-wrapper input[type="submit"], +:where(.editor-styles-wrapper) button { margin-bottom: 30px; padding: 10px 20px; background-color: #77CC6D; @@ -311,10 +323,15 @@ .editor-styles-wrapper input[type="reset"]:hover, .editor-styles-wrapper input[type="button"]:hover, .editor-styles-wrapper input[type="submit"]:hover, -.editor-styles-wrapper button:hover { +:where(.editor-styles-wrapper) button:hover { background-color: #33363B; } +/* The Search block button is not a

', + '', +].join("\n"); + +const targets: Record = { + heading: "h2.wp-block-heading", + text: "p", + link: "p a", + list: "li", + "classic quote": "blockquote:not(.wp-block-quote)", + submit: "input[type=submit]", + button: "p button", + "search field": ".wp-block-search__input", + "search button": ".wp-block-search__button", +}; + +async function styles(root: Page | Frame, scope: string, title: string) { + return root.locator(scope).first().evaluate( + (el, { targets, title }) => { + const read = (node: Element | null) => { + if (!node) return null; + const c = getComputedStyle(node); + return `${c.fontFamily.split(",")[0].replace(/["']/g, "")} ${c.fontSize} ${c.fontWeight} ${c.color} on ${c.backgroundColor}`; + }; + const out: Record = { title: read(document.querySelector(title)) }; + for (const [name, selector] of Object.entries(targets)) out[name] = read(el.querySelector(selector)); + return out; + }, + { targets, title }, + ); +} + +/** Set Customizer values through the Customizer's own API and publish them; returns the previous values. */ +async function saveCustomizer(page: Page, values: Record): Promise> { + await page.goto("/wp-admin/customize.php", { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => (window as any).wp?.customize?.state?.("activated")?.get(), null, { timeout: 90_000 }); + return page.evaluate(async (values) => { + const api = (window as any).wp.customize; + const previous: Record = {}; + for (const [key, value] of Object.entries(values)) { + const setting = api(`accelerate[${key}]`); + if (!setting) throw new Error(`Customizer setting accelerate[${key}] is not registered`); + previous[key] = setting.get(); + setting.set(value); + } + await new Promise((resolve, reject) => api.previewer.save().done(resolve).fail(reject)); + return previous; + }, values); +} + +async function createPost(page: Page, type: "posts" | "pages", title: string): Promise<{ id: number; link: string }> { + const res = await page.request.post(`/?rest_route=/wp/v2/${type}`, { + headers: { "X-WP-Nonce": await restNonce(page) }, + data: { title, content, status: "publish" }, + }); + expect(res.ok(), `creating the test ${type} failed: HTTP ${res.status()}`).toBeTruthy(); + const body = await res.json(); + return { id: body.id, link: body.link }; +} + +// POST with a method override: some hosts answer a bare DELETE with 405. +async function deletePost(page: Page, type: "posts" | "pages", id: number): Promise { + const res = await page.request.post(`/?rest_route=/wp/v2/${type}/${id}&force=true`, { + headers: { "X-WP-Nonce": await restNonce(page), "X-HTTP-Method-Override": "DELETE" }, + }); + expect(res.ok(), `deleting test ${type} ${id} failed: HTTP ${res.status()}`).toBeTruthy(); +} + +const fontsHref = (href: string | null) => (href ?? "").replace(/^https?:/, "").replace(/&(amp;|#038;)?ver=[^&]*/, ""); + +test("the block editor uses the front end's fonts, typography and colors @block-editor @fresh", async ({ + page, +}, testInfo) => { + requireAdmin(testInfo); + test.setTimeout(420_000); + const iframeWarnings: string[] = []; + page.on("console", (message) => { + if (/accelerate.*added to the iframe incorrectly/.test(message.text())) iframeWarnings.push(message.text()); + }); + await login(page); + let previous: Record | null = null; + const created: { type: "posts" | "pages"; id: number }[] = []; + try { + previous = await saveCustomizer(page, settings); + + for (const type of ["posts", "pages"] as const) { + const item = await createPost(page, type, `TGQA editor styles ${type}`); + created.push({ type, id: item.id }); + + await page.goto(item.link); + const front = await styles(page, ".entry-content", ".entry-title"); + // No link when only standard (system) fonts are chosen. + const frontFonts = await page.locator("#accelerate_googlefonts-css").evaluateAll((links) => links[0]?.getAttribute("href") ?? null); + + await page.goto(`/wp-admin/post.php?post=${item.id}&action=edit`, { waitUntil: "domcontentloaded" }); + const canvas = page.frameLocator('iframe[name="editor-canvas"]'); + await expect(canvas.locator(".editor-styles-wrapper .wp-block-search__input")).toBeVisible({ timeout: 90_000 }); + const frame = page.frame({ name: "editor-canvas" })!; + + await expect(canvas.locator("#accelerate-block-editor-styles-css"), "the theme's editor styles must reach the canvas").toBeAttached({ timeout: 15_000 }); + const editorFonts = await canvas.locator("#accelerate-editor-googlefonts-css").evaluateAll((links) => links[0]?.getAttribute("href") ?? null); + expect.soft(editorFonts && fontsHref(editorFonts), `${type}: the editor canvas must load the front end's fonts stylesheet`).toBe(frontFonts && fontsHref(frontFonts)); + + const editor = await styles(frame, ".editor-styles-wrapper", ".editor-post-title__input"); + for (const name of Object.keys(front)) { + expect(front[name], `${type}: ${name} is missing on the front end`).not.toBeNull(); + expect.soft(editor[name], `${type}: ${name} (font size weight color on background) differs from the front end`).toBe(front[name]); + } + } + expect(iframeWarnings).toEqual([]); + } finally { + for (const { type, id } of created) await deletePost(page, type, id); + if (previous) await saveCustomizer(page, previous); + } +}); diff --git a/tests/e2e/utils/wp.ts b/tests/e2e/utils/wp.ts new file mode 100644 index 0000000..32e37fd --- /dev/null +++ b/tests/e2e/utils/wp.ts @@ -0,0 +1,105 @@ +import { expect, type Page, type TestInfo } from "@playwright/test"; + +/** + * Shared helpers for specs that need wp-admin. Specs that change site state + * put it back in a finally block. + */ + +export const adminUser = process.env.TGQA_ADMIN_USER ?? ""; +export const adminPass = process.env.TGQA_ADMIN_PASS ?? ""; + +/** Skip, with a reason in the report, when no admin credentials were supplied. */ +export function requireAdmin(testInfo: TestInfo): void { + testInfo.skip( + !adminUser || !adminPass, + "needs TGQA_ADMIN_USER / TGQA_ADMIN_PASS in .themegrill-qa/.env.local", + ); +} + +export async function login(page: Page): Promise { + await page.goto("/wp-login.php"); + await page.locator("#user_login").fill(adminUser); + await page.locator("#user_pass").fill(adminPass); + await page.locator("#wp-submit").click(); + // The dashboard can take several seconds to load on a busy site. + await expect(page.locator("#wpadminbar")).toBeVisible({ timeout: 30_000 }); +} + +/** A REST nonce for the logged-in session, so page.request can call wp/v2. */ +export async function restNonce(page: Page): Promise { + const res = await page.request.get("/wp-admin/admin-ajax.php?action=rest-nonce"); + expect(res.ok(), "could not get a REST nonce — is the admin logged in?").toBeTruthy(); + return (await res.text()).trim(); +} + +/** Create a published page through the REST API and return its id and link. */ +export async function createPage( + page: Page, + title: string, + content: string, +): Promise<{ id: number; link: string }> { + const nonce = await restNonce(page); + const res = await page.request.post("/?rest_route=/wp/v2/pages", { + headers: { "X-WP-Nonce": nonce }, + data: { title, content, status: "publish" }, + }); + expect(res.ok(), `creating the test page failed: HTTP ${res.status()}`).toBeTruthy(); + const body = await res.json(); + return { id: body.id, link: body.link }; +} + +// POST with a method override rather than DELETE: some hosts (this Local site's +// nginx among them) answer a bare DELETE with 405 before WordPress sees it. +export async function deletePage(page: Page, id: number): Promise { + const nonce = await restNonce(page); + const res = await page.request.post(`/?rest_route=/wp/v2/pages/${id}&force=true`, { + headers: { "X-WP-Nonce": nonce, "X-HTTP-Method-Override": "DELETE" }, + }); + expect(res.ok(), `deleting test page ${id} failed: HTTP ${res.status()}`).toBeTruthy(); +} + +/** Remove a widget through the REST API (method override, as for pages above). */ +export async function removeWidget(page: Page, id: string): Promise { + const res = await page.request.post(`/?rest_route=/wp/v2/widgets/${id}&force=true`, { + headers: { "X-WP-Nonce": await restNonce(page), "X-HTTP-Method-Override": "DELETE" }, + }); + expect(res.ok(), `removing test widget ${id} failed: HTTP ${res.status()}`).toBeTruthy(); +} + +/** + * Add a classic (legacy) widget to a sidebar through the REST API, the way the block Widgets + * editor does: encode the form, create the widget, then place it (over HTTP a new widget + * lands in Inactive Widgets). `fields` are the widget form's field names and values. + */ +export async function addLegacyWidget( + page: Page, + idBase: string, + sidebar: string, + fields: Record, +): Promise { + const headers = { "X-WP-Nonce": await restNonce(page) }; + const form = Object.entries(fields) + .map(([k, v]) => `widget-${idBase}[1][${k}]=${encodeURIComponent(String(v))}`) + .join("&"); + const encoded = await page.request.post(`/?rest_route=/wp/v2/widget-types/${idBase}/encode`, { headers, data: { form_data: form } }); + expect(encoded.ok(), `encoding the ${idBase} widget failed: HTTP ${encoded.status()}`).toBeTruthy(); + const created = await page.request.post("/?rest_route=/wp/v2/widgets", { + headers, + data: { id_base: idBase, sidebar, instance: (await encoded.json()).instance }, + }); + expect(created.ok(), `creating the ${idBase} widget failed: HTTP ${created.status()}`).toBeTruthy(); + const id: string = (await created.json()).id; + try { + const placed = await page.request.post(`/?rest_route=/wp/v2/widgets/${id}`, { + headers: { ...headers, "X-HTTP-Method-Override": "PUT" }, + data: { sidebar }, + }); + expect(placed.ok(), `placing the ${idBase} widget failed: HTTP ${placed.status()}`).toBeTruthy(); + } catch (error) { + // The caller never gets the id, so its cleanup cannot remove this widget. + // A cleanup failure must not hide the placement error, which is the real cause. + await removeWidget(page, id).catch(() => {}); + throw error; + } + return id; +}