Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
Bug: the edit page's remove button (on a current relation) was a plain boosted
form — POST /unrelate -> 303 redirect -> the engine re-rendered #content, and the
freshly-swapped relate picker came back EMPTY ("the list of posts to relate" was
cleared).
Fix: make the remove button an AJAX in-place delete, exactly like the relate
candidate rows — each current-relation <li> gets an id and its form carries
sx-post + sx-target=#cur-<kind>-<other> + sx-swap=delete. unrelate-submit returns
an empty 200 for that request so the engine deletes just that one row; #content is
never re-rendered, so the picker is untouched. method+action stay for no-JS.
The empty-200 is gated on the SX-Target header (sent only by the sx-post form), so
a plain boosted form / no-JS POST still redirects + re-renders — the is-a-tag
toggle and graceful degradation are unaffected.
Tests (all red before the fix):
- lib/host/playwright/relate-picker.spec.js: the remove-button test now asserts
the picker still has candidates after a removal (the reproduction).
- web/tests/test-relate-picker.sx: an SX engine test — removing a current relation
deletes just that row and leaves the sibling picker's list intact.
- lib/host/tests/blog.sx: the relation-editor renders the AJAX delete attrs;
unrelate returns empty-200 with SX-Target and 303 without.
Verified: host conformance 275/275, web engine suite 8/8, run-picker-check 2/2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
94 lines
5.1 KiB
JavaScript
94 lines
5.1 KiB
JavaScript
// Browser check for the relate picker (lib/host/blog.sx). Runs against an
|
|
// ephemeral host server seeded with a host post + 25 candidates by
|
|
// run-picker-check.sh, which copies this spec into the Playwright env and sets
|
|
// SX_TEST_URL.
|
|
//
|
|
// TRIMMED to the irreducibly-real-browser cases. The picker's interactive
|
|
// behaviours — populate-on-load, debounced filter, sentinel paging, relate→delete
|
|
// row, error/retry visible state — are now SX engine tests in
|
|
// web/tests/test-relate-picker.sx (they drive the SAME engine against a mock DOM,
|
|
// no Chromium). Its server contract + persistence are SX conformance tests in
|
|
// lib/host/tests/blog.sx. What remains here needs a live boosted-SPA browser:
|
|
// 1. a boosted form POST swaps in place (bind-boost-form regression), and
|
|
// 2. the picker re-binds its triggers on content brought in by a boosted SPA
|
|
// nav (the case an inline <script> picker silently failed).
|
|
const { test, expect } = require('playwright/test');
|
|
|
|
const USER = process.env.SX_ADMIN_USER || 'admin';
|
|
const PASS = process.env.SX_ADMIN_PASSWORD || 'letmein';
|
|
const HOST = 'picker-host'; // the post whose edit page we drive
|
|
// the Related picker box (the edit page now has one picker per kind)
|
|
const REL = '.relate-picker[data-kind="related"]';
|
|
const RELF = `${REL} .rp-filter`;
|
|
const RELR = `${REL} .rp-results`;
|
|
const RELROWS = `${RELR} li:not(.rp-more)`; // candidate rows (exclude the sentinel)
|
|
|
|
// boot-init marks <html data-sx-ready="true"> once the WASM kernel + web stack
|
|
// load. WASM compile + asset fetches, so allow generous time.
|
|
async function waitReady(page) {
|
|
await expect(page.locator('html[data-sx-ready="true"]')).toHaveCount(1, { timeout: 45000 });
|
|
}
|
|
|
|
// Navigate to a GUARDED path; the host redirects to /login?next=…, so fill the
|
|
// form and we should land back on the original path (exercises the auth flow).
|
|
async function loginTo(page, path) {
|
|
await page.goto(path);
|
|
await page.waitForURL(/\/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'));
|
|
}
|
|
|
|
// Log in directly (for reaching PUBLIC pages while authenticated).
|
|
async function login(page) {
|
|
await page.goto('/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('relate picker (browser-only)', () => {
|
|
test('the remove button on a current relation actually unrelates it', async ({ page }) => {
|
|
test.setTimeout(75000);
|
|
await loginTo(page, `/${HOST}/edit`);
|
|
await waitReady(page);
|
|
// relate Item 13 via the picker, then reload so it shows in the current list
|
|
await page.fill(RELF, 'Item 13');
|
|
await expect.poll(() => page.locator(RELROWS).count(), { timeout: 10000 }).toBe(1);
|
|
await page.locator(`${RELROWS} button`).first().click();
|
|
await expect.poll(() => page.locator(RELROWS).count(), { timeout: 10000 }).toBe(0);
|
|
await page.reload();
|
|
await waitReady(page);
|
|
const relLink = page.locator('a[href="/picker-item-13/"]');
|
|
await expect(relLink).toHaveCount(1); // current relation present
|
|
// the picker is populated (empty filter -> first page of candidates)
|
|
await expect.poll(() => page.locator(RELROWS).count(), { timeout: 12000 }).toBeGreaterThan(0);
|
|
// click its remove button
|
|
await page.locator('li:has(a[href="/picker-item-13/"]) button').click();
|
|
await expect(relLink).toHaveCount(0, { timeout: 12000 }); // relation removed
|
|
// REGRESSION: removing a relation must NOT clear "the list of posts to relate".
|
|
// (The old plain-boosted remove form redirected -> re-rendered #content and the
|
|
// picker came back empty; the AJAX in-place remove leaves the picker untouched.)
|
|
await expect.poll(() => page.locator(RELROWS).count(), { timeout: 12000 }).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('picker populates after a boosted SPA nav to the edit page', async ({ page }) => {
|
|
// Reach the edit page by CLICKING its link (a boosted SPA nav), not page.goto.
|
|
// The old inline <script> picker never ran on swapped-in content, so the list
|
|
// stayed empty here. The declarative form's "load" trigger is re-bound by the
|
|
// engine on swap, so it populates — that's the regression this guards.
|
|
await login(page);
|
|
await page.goto(`/${HOST}/`); // public post page, logged in
|
|
await waitReady(page);
|
|
await page.evaluate(() => { window.__noReload = true; });
|
|
await page.locator(`a[href="/${HOST}/edit"]`).first().click();
|
|
await page.waitForURL((u) => u.pathname === `/${HOST}/edit`, { timeout: 15000 });
|
|
expect(await page.evaluate(() => window.__noReload)).toBe(true); // it was a SPA nav, no full reload
|
|
// the picker, brought in by the swap, loaded its first page of candidates
|
|
await expect.poll(() => page.locator(RELROWS).count(), { timeout: 12000 }).toBeGreaterThanOrEqual(1);
|
|
await expect(page.locator(RELR)).toContainText('Picker Item');
|
|
});
|
|
});
|