Skip to content
Open
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
4 changes: 4 additions & 0 deletions .themegrill-qa/suite.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
"inc/header-functions.php",
"inc/customizer.php",
"js/skip-link-focus-fix.js"
],
"widgets": [
"inc/widgets/**",
"tests/e2e/utils/**"
]
}
}
2 changes: 1 addition & 1 deletion inc/widgets/accelerate-call-to-action-widget.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ function widget( $args, $instance ) {
$text_main = empty( $instance['text_main'] ) ? '' : $instance['text_main'];
$text_additional = empty( $instance['text_additional'] ) ? '' : $instance['text_additional'];
$button_text = isset( $instance[ 'button_text' ] ) ? $instance[ 'button_text' ] : '';
$button_url = isset( $instance[ 'button_url' ] ) ? $instance[ 'button_url' ] : '#';
$button_url = empty( $instance['button_url'] ) ? '#' : $instance['button_url'];

echo $before_widget;
?>
Expand Down
37 changes: 37 additions & 0 deletions tests/e2e/specs/widgets/cta-empty-link.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test, expect } from "@playwright/test";
import { addLegacyWidget, createPage, deletePage, login, removeWidget, requireAdmin } from "../../utils/wp";

/**
* @area widgets
* @tier fresh
* @guards accelerate-pro#54
* @source accelerate-pro#54 (reported by iamprazol)
* @why The widget always saves button_url (as "" when left empty), so the
* isset() fallback to "#" never applied and the button rendered href="",
* which reloads the current page on click.
*/
test("TG: Call to Action button falls back to # when no link is set @widgets @fresh", async ({ page }, testInfo) => {
requireAdmin(testInfo);
test.setTimeout(120_000);
await login(page);
let hostId = 0;
let widget = "";
try {
const host = await createPage(page, "TGQA call to action host", "");
hostId = host.id;
widget = await addLegacyWidget(page, "accelerate_call_to_action_widget", "accelerate_right_sidebar", {
text_main: "TGQA call to action",
text_additional: "",
button_text: "Get Started",
button_url: "",
});

await page.goto(host.link);
const button = page.locator(`#${widget} a.read-more`);
await expect(button, "the call to action button is not rendered").toBeVisible();
await expect(button).toHaveAttribute("href", "#");
} finally {
if (widget) await removeWidget(page, widget);
if (hostId) await deletePage(page, hostId);
}
});
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