Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .themegrill-qa/suite.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
60 changes: 57 additions & 3 deletions inc/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/****************************************************************************************/

Expand Down
25 changes: 21 additions & 4 deletions style-editor-block.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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;
Expand All @@ -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 <button> in the editor; on the front end it keeps the button radius. */
:where(.editor-styles-wrapper) .wp-block-search__button {
border-radius: 4px;
}

/* Links */
.editor-styles-wrapper a {
color: #77CC6D;
Expand Down
143 changes: 143 additions & 0 deletions tests/e2e/specs/block-editor/customizer-styles.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { test, expect, type Frame, type Page } from "@playwright/test";
import { login, requireAdmin, restNonce } from "../../utils/wp";

/**
* @area block-editor
* @tier fresh
* @guards accelerate-pro#47
* @source accelerate-pro#47 (reported by iamprazol)
* @why The block editor never used the Customizer: it loaded a static stylesheet
* and a hardcoded Roboto link on a hook whose styles never reach the editor
* iframe, so no theme font loaded in the canvas, and the title, form fields,
* buttons and the Customizer primary color differed from the front end.
* Saves a custom primary color through the Customizer itself (restored
* afterwards), then checks on a post and a page
* that the canvas carries the front end's fonts stylesheet, that the theme's
* editor styles reach the iframe the supported way, and that each element
* computes the same font, size, weight, color and background in the editor
* as on the published page. Reads the stylesheet links, so it needs no
* network access to Google.
*/
const settings: Record<string, string> = {
accelerate_primary_color: "#d63384",
};

const content = [
'<!-- wp:heading --><h2 class="wp-block-heading">TGQA heading</h2><!-- /wp:heading -->',
'<!-- wp:paragraph --><p>TGQA text with <a href="https://example.com">a link</a>.</p><!-- /wp:paragraph -->',
'<!-- wp:list --><ul class="wp-block-list"><!-- wp:list-item --><li>TGQA list item</li><!-- /wp:list-item --></ul><!-- /wp:list -->',
'<!-- wp:freeform --><blockquote><p>TGQA classic quote</p></blockquote><p><input type="submit" value="Go" /> <button type="button">Plain button</button></p><!-- /wp:freeform -->',
'<!-- wp:search {"label":"Search","buttonText":"Search"} /-->',
].join("\n");
Comment thread
iamprazol marked this conversation as resolved.

const targets: Record<string, string> = {
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<string, string | null> = { 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<string, string>): Promise<Record<string, string>> {
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<string, string> = {};
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<void> {
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<string, string> | 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);
}
});
105 changes: 105 additions & 0 deletions tests/e2e/utils/wp.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string> {
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<void> {
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<void> {
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<string, string | number>,
): Promise<string> {
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;
}
Loading