Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 30s
Two reported bugs on the edit page's relation editor:
1. relating a candidate didn't add it to the current-relations list (the AJAX
relate just deleted the candidate row; the relation only showed after a reload);
2. removing a relation could blank the relate picker.
Fix (lib/host/blog.sx): both the candidate's relate form and a current relation's
remove form now target #rel-editor-<kind> with sx-swap=outerHTML, and the
relate/unrelate handlers return the re-rendered editor for that kind (current list +
a fresh picker). So one swap keeps BOTH lists in sync: the related post moves into
the current list and out of the (re-loaded) candidate pool; removing moves it back.
Gated on the SX-Target header, so a plain boosted form / no-JS POST (the is-a-tag
toggle) still redirects + re-renders #content.
Engine fix (web/orchestration.sx): handle-html-response's non-select branch called
post-swap on the OLD target, which an outerHTML swap has already REPLACED — so the
swapped-in content's triggers (here the re-rendered picker's "load") never bound and
the picker stayed empty. post-swap the swap result (the new node), mirroring the
sx-select branch. Recompiled orchestration.sxbc for the content-addressed client.
Tests:
- web/tests/test-relate-picker.sx: relating re-syncs the editor (post in current
list + picker re-loads); removing does likewise — both fail without the engine fix.
- lib/host/tests/blog.sx: relate/unrelate return the re-rendered editor fragment
(200, #rel-editor + picker), forms wire to #rel-editor-KIND/outerHTML, plain
boosted POST still 303.
- relate-picker.spec.js: the full in-page flow (relate adds to list, remove keeps
the picker, no reload) + persistence.
Verified: host conformance 277/277, web engine suite 8/8, run-picker-check 3/3,
run-spa-check 3/3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
119 lines
6.4 KiB
JavaScript
119 lines
6.4 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('relating a candidate adds it to the current list AND removing keeps the picker', async ({ page }) => {
|
|
// The whole in-page flow the user reported broken — no reloads. Relating a
|
|
// candidate re-renders the editor: the post moves into the current-relations
|
|
// list and the picker re-loads its candidates (it is NOT blanked). Removing it
|
|
// re-renders the editor back: the post leaves the current list and the picker
|
|
// still offers candidates.
|
|
test.setTimeout(75000);
|
|
await loginTo(page, `/${HOST}/edit`);
|
|
await waitReady(page);
|
|
await page.evaluate(() => { window.__noReload = true; });
|
|
// relate Item 13 from the picker
|
|
await page.fill(RELF, 'Item 13');
|
|
await expect.poll(() => page.locator(RELROWS).count(), { timeout: 10000 }).toBe(1);
|
|
await page.locator(`${RELROWS} button`).first().click();
|
|
const relLink = page.locator('a[href="/picker-item-13/"]');
|
|
// ISSUE 1: it now appears in the CURRENT relations list (added, not just removed)
|
|
await expect(relLink).toHaveCount(1, { timeout: 12000 });
|
|
// and the re-rendered picker still offers candidates (not blanked)
|
|
await expect.poll(() => page.locator(RELROWS).count(), { timeout: 12000 }).toBeGreaterThan(0);
|
|
// now remove it via its current-list remove button
|
|
await page.locator('li:has(a[href="/picker-item-13/"]) button').click();
|
|
await expect(relLink).toHaveCount(0, { timeout: 12000 }); // left the current list
|
|
// ISSUE 2: removing must NOT clear "the list of posts to relate"
|
|
await expect.poll(() => page.locator(RELROWS).count(), { timeout: 12000 }).toBeGreaterThan(0);
|
|
expect(await page.evaluate(() => window.__noReload)).toBe(true); // all in-page, no reload
|
|
// and the relation truly persisted gone (reload shows it not present)
|
|
await page.reload();
|
|
await waitReady(page);
|
|
await expect(page.locator('a[href="/picker-item-13/"]')).toHaveCount(0);
|
|
});
|
|
|
|
test('relating a candidate persists the relation', async ({ page }) => {
|
|
test.setTimeout(75000);
|
|
await loginTo(page, `/${HOST}/edit`);
|
|
await waitReady(page);
|
|
await page.fill(RELF, 'Item 07');
|
|
await expect.poll(() => page.locator(RELROWS).count(), { timeout: 10000 }).toBe(1);
|
|
await page.locator(`${RELROWS} button`).first().click();
|
|
await expect(page.locator('a[href="/picker-item-07/"]')).toHaveCount(1, { timeout: 12000 });
|
|
// persisted across a reload
|
|
await page.reload();
|
|
await waitReady(page);
|
|
await expect(page.locator('a[href="/picker-item-07/"]')).toHaveCount(1);
|
|
// and visible on the public post page
|
|
await page.goto(`/${HOST}/`);
|
|
await expect(page.getByRole('heading', { name: 'Related posts' })).toBeVisible();
|
|
await expect(page.locator('body')).toContainText('Picker Item 07');
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|