HEX
Server: LiteSpeed
System: Linux server342.web-hosting.com 4.18.0-553.124.4.lve.el8.x86_64 #1 SMP Fri May 15 13:02:13 UTC 2026 x86_64
User: ksonpoau (1099)
PHP: 8.2.31
Disabled: NONE
Upload Files
File: //proc/self/cwd/wp-content/plugins/extendify/tests/playwright/QuickEdit/reload-flows/undo.spec.ts
import { expect, test } from '../../fixtures';

// Characterization: Cmd+Z after a reload-flow save. The
// inline-text spec pins the round-trip for the default `save()` branch; this
// spec pins the keyboard → store → REST round-trip for the two non-text
// branches the reload-flows blueprint exposes — `identityReplay` (site
// identity) and `navReplay` (ref-based nav). Both write to localStorage at
// save time, survive the reload, and dispatch through performUndo on Cmd+Z.
//
// Assertions pin the undo POST body, not just the HTTP 200, so a future undo
// refactor can't silently no-op into a successful 200 that didn't actually
// replay the pre-mutation values. The post-reload DOM assertion catches the
// other half: that the controller really did apply the patch.

const adminBarPill = (page) => page.locator('#ext-tb-quick-edit');

const hoverBar = (page) => page.locator('.extendify-quick-edit-bar');

const enableEditMode = async (page) => {
	await page.addInitScript(() => {
		window.localStorage.setItem(
			'extendify-quick-edit-mode',
			JSON.stringify({ state: { on: true }, version: 0 }),
		);
	});
};

const hoverAndOpenModal = async (page, locator) => {
	await locator.scrollIntoViewIfNeeded();
	await locator.hover();
	const bar = hoverBar(page);
	await expect(bar).toBeVisible();
	await bar.getByRole('button', { name: /Quick Edit/ }).click();
};

const dialog = (page, name: RegExp) => page.getByRole('dialog', { name });

// Prefill modals mount their labeled inputs only after an async REST GET
// resolves — until then they show a Spinner and keep Save disabled
// (disabled={saving || !data}). On a starved CI runner that GET can outlast
// expect()'s 5s default, racing the input assertions. Gate on Save enabled
// (= data loaded) before touching the inputs.
const waitForModalData = (modal) =>
	expect(modal.getByRole('button', { name: /^Save$/ })).toBeEnabled({
		timeout: 15_000,
	});

// The modal save reloads the page; QE must re-mount and re-bind its Cmd+Z
// handler before the undo press. mount() appends #extendify-quick-edit-root
// just before calling attachKeyboardUndo(), so that host is the post-reload
// readiness signal — on a starved runner the bundle can still be loading when
// the press would otherwise fire (root absent → no handler, undo no-ops).
const waitForQuickEditReady = (page) =>
	expect(page.locator('#extendify-quick-edit-root')).toBeAttached({
		timeout: 15_000,
	});

test.beforeEach(async ({ requestUtils }) => {
	await requestUtils.login();
});

test('Cmd+Z after a site title save POSTs the original title back through /quick-edit/site-identity and the header re-renders the original value', async ({
	page,
}) => {
	await enableEditMode(page);
	await page.goto('/');
	await expect(adminBarPill(page)).toBeVisible({ timeout: 15_000 });

	const headerSiteTitle = page
		.getByRole('banner')
		.locator('.wp-block-site-title')
		.first();
	await hoverAndOpenModal(page, headerSiteTitle);

	const modal = dialog(page, /Site title/i);
	await waitForModalData(modal);
	const titleInput = modal.getByLabel(/Site title/i);
	await expect(titleInput).toHaveValue('Original Site Title');
	await titleInput.fill('Renamed via Quick Edit (undo)');

	const saved = page.waitForResponse(
		(r) =>
			r.url().includes('/quick-edit/site-identity') &&
			r.request().method() === 'POST' &&
			r.status() === 200,
	);
	const reloaded = page.waitForLoadState('load');
	await modal.getByRole('button', { name: /^Save$/ }).click();
	await saved;
	await reloaded;

	// Sanity-check the post-save DOM before driving the undo — pins that the
	// forward half of the round-trip really landed.
	await expect(
		page
			.getByRole('banner')
			.locator('.wp-block-site-title')
			.filter({ hasText: 'Renamed via Quick Edit (undo)' }),
	).toBeVisible();

	await waitForQuickEditReady(page);

	// SiteIdentityModal stamps `beforeValues = { title: '<original>' }` into
	// the undo entry; performUndo dispatches on `identityReplay` and POSTs
	// beforeValues straight to /quick-edit/site-identity.
	const undoSaved = page.waitForResponse(
		(r) =>
			r.url().includes('/quick-edit/site-identity') &&
			r.request().method() === 'POST' &&
			r.status() === 200,
	);
	const undoReloaded = page.waitForLoadState('load');
	await page.locator('body').press('ControlOrMeta+z');
	const undoRes = await undoSaved;
	expect(JSON.parse(undoRes.request().postData() || '{}')).toEqual({
		title: 'Original Site Title',
	});
	await undoReloaded;

	await expect(
		page
			.getByRole('banner')
			.locator('.wp-block-site-title')
			.filter({ hasText: 'Original Site Title' }),
	).toBeVisible();
	await expect(
		page
			.getByRole('banner')
			.locator('.wp-block-site-title')
			.filter({ hasText: 'Renamed via Quick Edit (undo)' }),
	).toHaveCount(0);
});

test('Cmd+Z after a ref-based nav label save POSTs the original label + URL patches back through /quick-edit/wp-navigation and the nav re-renders the original label', async ({
	page,
}) => {
	await enableEditMode(page);
	await page.goto('/');
	await expect(adminBarPill(page)).toBeVisible({ timeout: 15_000 });

	const aboutItem = page
		.getByRole('banner')
		.locator('.wp-block-navigation-item')
		.filter({ hasText: 'Original About' });
	await hoverAndOpenModal(page, aboutItem);

	const modal = dialog(page, /Edit navigation link/i);
	const labelInput = modal.getByLabel(/^Label$/i);
	await expect(labelInput).toHaveValue('Original About');
	await labelInput.fill('About Renamed (undo)');

	const saved = page.waitForResponse(
		(r) =>
			r.url().includes('/quick-edit/wp-navigation') &&
			r.request().method() === 'POST' &&
			r.status() === 200,
	);
	const reloaded = page.waitForLoadState('load');
	await modal.getByRole('button', { name: /^Save$/ }).click();
	await saved;
	await reloaded;

	await expect(
		page
			.getByRole('banner')
			.locator('.wp-block-navigation-item')
			.filter({ hasText: 'About Renamed (undo)' }),
	).toBeVisible();

	await waitForQuickEditReady(page);

	// NavItemModal stamps the undo entry with BOTH label + url at their
	// pre-mutation values — even though only label was dirty on the forward
	// save. The URL patch is idempotent (replays /about → /about) but a
	// hand-edited URL post-save would still be restored on undo.
	const undoSaved = page.waitForResponse(
		(r) =>
			r.url().includes('/quick-edit/wp-navigation') &&
			r.request().method() === 'POST' &&
			r.status() === 200,
	);
	const undoReloaded = page.waitForLoadState('load');
	await page.locator('body').press('ControlOrMeta+z');
	const undoRes = await undoSaved;
	const undoBody = JSON.parse(undoRes.request().postData() || '{}');
	expect(Number(undoBody.navPostId)).toBeGreaterThan(0);
	expect(undoBody.itemIndex).toBe(1);
	expect(undoBody.blockType).toBe('core/navigation-link');
	expect(undoBody.patches).toEqual([
		{ fieldKey: 'label', value: 'Original About' },
		{ fieldKey: 'url', value: '/about' },
	]);
	await undoReloaded;

	await expect(
		page
			.getByRole('banner')
			.locator('.wp-block-navigation-item')
			.filter({ hasText: 'Original About' }),
	).toBeVisible();
	await expect(
		page
			.getByRole('banner')
			.locator('.wp-block-navigation-item')
			.filter({ hasText: 'About Renamed (undo)' }),
	).toHaveCount(0);
});