Files
rose-ash/lib/host/playwright/relate-picker.spec.js
giles 697931bf41 host: Playwright check for the relate picker (+ 2 bugs it caught)
Wire a browser check for the picker, run it against an ephemeral host server,
and fix the two real bugs it surfaced.

- lib/host/playwright/relate-picker.spec.js — drives login-redirect-return,
  JS candidate load + infinite scroll, debounced filter, and click-to-relate
  (asserting the relation shows on the post page).
- lib/host/playwright/run-picker-check.sh — spins up an ephemeral host server
  (this worktree's binary + lib, temp persist), seeds a host post + 25
  candidates, runs the spec in the main worktree's Playwright/chromium, tears
  everything down. No live-site dependency, no live-data pollution. 4/4 pass.

Bugs the check caught:
1. Query params weren't %-decoded — dream's form parser decodes but its query
   parser doesn't, so a filter "Item 13" arrived as "Item%2013" and matched
   nothing. Fix: decode q with dream's own dr/url-decode in host/blog-relate-
   options. (+ conformance test for a spaced filter.)
2. A filter typed while a load was in flight got dropped (busy guard returned
   with no trailing fetch). Fix: a `pending` flag re-runs the load when the
   in-flight one finishes, coalescing to the latest query.

239/239 conformance; JS node --check clean. Verified live: spaced filter
returns matches; served JS carries the pending-reload fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:07:47 +00:00

63 lines
3.2 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. Exercises the login redirect, the JS-driven candidate load,
// debounced filter, infinite scroll, and click-to-relate.
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
const LIMIT = 20; // host/blog--picker-limit
// 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'));
}
test.describe('relate picker', () => {
test('login redirect returns to the edit page', async ({ page }) => {
await loginTo(page, `/${HOST}/edit`);
await expect(page).toHaveURL(new RegExp(`/${HOST}/edit`));
await expect(page.locator('#relate-filter')).toBeVisible();
});
test('picker loads a page of candidates then loads more on scroll', async ({ page }) => {
await loginTo(page, `/${HOST}/edit`);
const rows = page.locator('#relate-results li');
// initial JS load fills exactly one page
await expect.poll(() => rows.count(), { timeout: 8000 }).toBe(LIMIT);
// scroll the results box to the bottom -> infinite scroll fetches the rest
await page.locator('#relate-results').evaluate((el) => el.scrollTo(0, el.scrollHeight));
await expect.poll(() => rows.count(), { timeout: 8000 }).toBeGreaterThan(LIMIT);
});
test('typing in the filter narrows the candidates', async ({ page }) => {
await loginTo(page, `/${HOST}/edit`);
await expect.poll(() => page.locator('#relate-results li').count(), { timeout: 8000 }).toBeGreaterThan(0);
await page.fill('#relate-filter', 'Item 13');
await expect.poll(() => page.locator('#relate-results li').count(), { timeout: 8000 }).toBe(1);
await expect(page.locator('#relate-results')).toContainText('Picker Item 13');
});
test('clicking a candidate relates it (and it shows on the post page)', async ({ page }) => {
await loginTo(page, `/${HOST}/edit`);
await page.fill('#relate-filter', 'Item 07');
await expect.poll(() => page.locator('#relate-results li').count(), { timeout: 8000 }).toBe(1);
await page.locator('#relate-results button').first().click();
// form POST -> 303 back to the edit page; the related list now links the slug
await expect(page).toHaveURL(new RegExp(`/${HOST}/edit`));
await expect(page.locator('a[href="/picker-item-07/"]')).toHaveCount(1);
// and the public post page shows the Related posts block with the title
await page.goto(`/${HOST}/`);
await expect(page.getByRole('heading', { name: 'Related posts' })).toBeVisible();
await expect(page.locator('body')).toContainText('Picker Item 07');
});
});