The add-block dropdown wrapped its <option>s in a <span> — (select :name "ctype" (span (option…)…)) — to splice a dynamic list. A <select> only renders <option>/<optgroup> direct children, so the dropdown was empty. A full-page load hid it (the browser's HTML parser hoists mis-nested options out of the select), but on a BOOSTED nav the DOM is built programmatically (no parser error-recovery), so the span stayed and the dropdown was empty. The card types are a fixed set — inline the options directly as <select> children. TEST-FIRST: 4th boost-nav.spec.js case (LOGGED IN: boosted nav to edit → assert select[name=ctype] > option count is 5, incl card-heading). RED before (0 direct-child options — span-wrapped), GREEN after. All 4 boost-nav tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
108 lines
5.9 KiB
JavaScript
108 lines
5.9 KiB
JavaScript
// Regression for the boosted-navigation link-rebinding bug (reported on blog.rose-ash.com):
|
|
// home --boosted nav--> a post --click "edit"--> lands on /tags (a HOME footer link),
|
|
// not /<slug>/edit. After a boost swap, the swapped-in links carry a STALE binding from
|
|
// the previous page. Run by run-boost-nav-check.sh against an ephemeral host server
|
|
// (serve.sh seeds /compose-demo + the home footer's /tags link).
|
|
const { test, expect } = require('playwright/test');
|
|
|
|
const BASE = process.env.SX_TEST_URL || 'http://127.0.0.1:8914';
|
|
|
|
const USER = process.env.SX_ADMIN_USER || 'admin';
|
|
const PASS = process.env.SX_ADMIN_PASSWORD || 'letmein';
|
|
|
|
async function waitReady(page) {
|
|
await expect(page.locator('html[data-sx-ready="true"]')).toHaveCount(1, { timeout: 45000 });
|
|
}
|
|
async function login(page) {
|
|
await page.goto(BASE + '/login');
|
|
await page.fill('input[name="username"]', USER);
|
|
await page.fill('input[name="password"]', PASS);
|
|
await page.click('button[type="submit"]');
|
|
await page.waitForURL((u) => !u.pathname.startsWith('/login'));
|
|
}
|
|
|
|
test.describe('boosted navigation (browser-only)', () => {
|
|
test('a post link clicked AFTER a boosted nav navigates to the right target (not a stale home link)', async ({ page }) => {
|
|
test.setTimeout(90000);
|
|
// 1) load HOME (its footer has a /tags link — the stale target the bug lands on)
|
|
await page.goto(BASE + '/');
|
|
await waitReady(page);
|
|
await expect(page.locator('a[href="/tags"]')).toHaveCount(1); // home has the /tags link
|
|
|
|
// 2) boosted nav HOME -> the composed post (no full reload)
|
|
await page.locator('a[href="/compose-demo/"]').first().click();
|
|
await expect(page.locator('body')).toContainText('composition object', { timeout: 15000 });
|
|
expect(page.url()).toContain('/compose-demo/');
|
|
|
|
// 3) click the post's "edit" link — brought in by the swap
|
|
await expect(page.locator('a[href="/compose-demo/edit"]')).toHaveCount(1);
|
|
await page.locator('a[href="/compose-demo/edit"]').click();
|
|
await page.waitForTimeout(3000);
|
|
|
|
// 4) it MUST navigate to the edit route (guarded -> the login view is fine, the URL is
|
|
// pushed to /compose-demo/edit), and MUST NOT land on the stale /tags link.
|
|
expect(page.url()).not.toContain('/tags');
|
|
expect(page.url()).toContain('/compose-demo/edit');
|
|
});
|
|
|
|
test('a guarded route reached via boost does a clean full-nav to /login (no clobbered SPA), and Home works from there', async ({ page }) => {
|
|
test.setTimeout(90000);
|
|
await page.goto(BASE + '/');
|
|
await waitReady(page);
|
|
// boosted nav home -> post
|
|
await page.locator('a[href="/compose-demo/"]').first().click();
|
|
await expect(page.locator('body')).toContainText('composition object', { timeout: 15000 });
|
|
// click "edit" (guarded, logged out). A 303 would be followed by the fetch WITHOUT the
|
|
// SX-Request header -> /login returns the full shell, which morphed into #content
|
|
// DESTROYS the swap target (then nothing navigates). The fix returns SX-Redirect, so the
|
|
// engine does a FULL navigation to a real /login page.
|
|
await page.locator('a[href="/compose-demo/edit"]').click();
|
|
await page.waitForTimeout(3500);
|
|
expect(new URL(page.url()).pathname).toBe('/login');
|
|
await expect(page.locator('body')).toContainText('Log in');
|
|
// and the login page offers a way back Home that works (the reported "Home does nothing").
|
|
await page.locator('a[href="/"]').first().click();
|
|
await page.waitForTimeout(3000);
|
|
await expect(page.locator('body')).toContainText('Posts', { timeout: 12000 });
|
|
expect(new URL(page.url()).pathname).toBe('/');
|
|
});
|
|
|
|
test('LOGGED IN: the Home nav works after a boosted nav to the edit page', async ({ page }) => {
|
|
test.setTimeout(90000);
|
|
await login(page); // authed session
|
|
await page.goto(BASE + '/');
|
|
await waitReady(page);
|
|
// boosted nav home -> post -> edit (authed, so the real edit form swaps into #content)
|
|
await page.locator('a[href="/compose-demo/"]').first().click();
|
|
await expect(page.locator('body')).toContainText('composition object', { timeout: 15000 });
|
|
// the footer "edit" link (there's also a "no relations — add some" link to edit when authed)
|
|
await page.locator('a[href="/compose-demo/edit"]').last().click();
|
|
await expect(page.locator('body')).toContainText('Edit:', { timeout: 15000 });
|
|
expect(new URL(page.url()).pathname).toBe('/compose-demo/edit');
|
|
// #content must SURVIVE the edit swap (an outerHTML swap would replace it, then no later
|
|
// nav can find a swap target — the reported "Home does nothing").
|
|
expect(await page.locator('#content').count()).toBe(1);
|
|
// the persistent top-nav Home link must still work on the edit page.
|
|
await page.locator('nav a[href="/"]').first().click();
|
|
await page.waitForTimeout(3000);
|
|
await expect(page.locator('body')).toContainText('Posts', { timeout: 12000 });
|
|
expect(new URL(page.url()).pathname).toBe('/');
|
|
});
|
|
|
|
test('LOGGED IN: the block-editor card-type dropdown populates after a boosted nav to edit', async ({ page }) => {
|
|
test.setTimeout(90000);
|
|
await login(page);
|
|
await page.goto(BASE + '/');
|
|
await waitReady(page);
|
|
await page.locator('a[href="/compose-demo/"]').first().click();
|
|
await expect(page.locator('body')).toContainText('composition object', { timeout: 15000 });
|
|
await page.locator('a[href="/compose-demo/edit"]').last().click();
|
|
await expect(page.locator('body')).toContainText('Edit:', { timeout: 15000 });
|
|
// the ctype <select> must have selectable <option> DIRECT children. A <span> wrapper
|
|
// leaves the dropdown empty when the DOM is built programmatically on a boosted swap
|
|
// (the HTML parser would hoist them out on a full load, hiding the bug there).
|
|
await expect(page.locator('#block-editor select[name="ctype"] > option')).toHaveCount(5, { timeout: 10000 });
|
|
await expect(page.locator('#block-editor select[name="ctype"] > option[value="card-heading"]')).toHaveCount(1);
|
|
});
|
|
});
|