diff --git a/lib/host/blog.sx b/lib/host/blog.sx index 384c174a..a3bb1521 100644 --- a/lib/host/blog.sx +++ b/lib/host/blog.sx @@ -155,7 +155,10 @@ (define host/blog-relate-options (fn (req) (let ((slug (dream-param req "slug")) - (q (or (dream-query-param req "q") "")) + ;; dream's query parser does not %-decode values (its form parser does), + ;; so a filter like "Item 13" arrives as "Item%2013" — decode it with + ;; dream's own dr/url-decode before matching. + (q (dr/url-decode (or (dream-query-param req "q") ""))) (offset (host/query-int req "offset" 0))) (let ((page (take (drop (host/blog--relate-candidates slug q) offset) host/blog--picker-limit))) @@ -167,7 +170,7 @@ ;; host serves static HTML (no SX hydration), so the interactive layer is a small ;; vanilla script served from this route (read once, cached). (define host/blog-picker-js-src - "(function(){var f=document.getElementById('relate-filter');if(!f)return;var r=document.getElementById('relate-results');var slug=f.getAttribute('data-slug'),off=0,q='',busy=false,done=false,t;function load(reset){if(busy||(!reset&&done))return;busy=true;if(reset){off=0;done=false;}fetch('/'+slug+'/relate-options?q='+encodeURIComponent(q)+'&offset='+off).then(function(x){return x.text();}).then(function(h){var d=document.createElement('div');d.innerHTML=h;var n=d.children.length;if(reset)r.innerHTML='';while(d.firstChild)r.appendChild(d.firstChild);off+=n;done=n<20;busy=false;}).catch(function(){busy=false;});}f.addEventListener('input',function(){clearTimeout(t);t=setTimeout(function(){q=f.value.trim();load(true);},200);});r.addEventListener('scroll',function(){if(r.scrollTop+r.clientHeight>=r.scrollHeight-40){load(false);}});load(true);})();") + "(function(){var f=document.getElementById('relate-filter');if(!f)return;var r=document.getElementById('relate-results');var slug=f.getAttribute('data-slug'),off=0,q='',busy=false,done=false,pending=false,t;function load(reset){if(busy){if(reset)pending=true;return;}if(!reset&&done)return;busy=true;if(reset){off=0;done=false;}fetch('/'+slug+'/relate-options?q='+encodeURIComponent(q)+'&offset='+off).then(function(x){return x.text();}).then(function(h){var d=document.createElement('div');d.innerHTML=h;var n=d.children.length;if(reset)r.innerHTML='';while(d.firstChild)r.appendChild(d.firstChild);off+=n;done=n<20;busy=false;if(pending){pending=false;load(true);}}).catch(function(){busy=false;if(pending){pending=false;load(true);}});}f.addEventListener('input',function(){clearTimeout(t);t=setTimeout(function(){q=f.value.trim();load(true);},200);});r.addEventListener('scroll',function(){if(r.scrollTop+r.clientHeight>=r.scrollHeight-40){load(false);}});load(true);})();") (define host/blog-picker-js (fn (req) (dream-response 200 {:content-type "application/javascript; charset=utf-8"} diff --git a/lib/host/playwright/relate-picker.spec.js b/lib/host/playwright/relate-picker.spec.js new file mode 100644 index 00000000..17f454a1 --- /dev/null +++ b/lib/host/playwright/relate-picker.spec.js @@ -0,0 +1,62 @@ +// 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'); + }); +}); diff --git a/lib/host/playwright/run-picker-check.sh b/lib/host/playwright/run-picker-check.sh new file mode 100755 index 00000000..4de0372c --- /dev/null +++ b/lib/host/playwright/run-picker-check.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Browser check for the relate picker. Spins up an EPHEMERAL host server (this +# worktree's binary + lib, a temp persist dir), seeds a host post + 25 candidates, +# runs lib/host/playwright/relate-picker.spec.js in the main worktree's Playwright, +# then tears everything down. No live-site dependency, no live-data pollution. +# +# bash lib/host/playwright/run-picker-check.sh +# +# Requires: the OCaml binary built (hosts/ocaml/_build/default/bin/sx_server.exe) +# and Playwright + chromium in /root/rose-ash (the architecture worktree). +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" +ROOT=$(pwd) + +PORT="${PICKER_PORT:-8912}" +PW_DIR="${PW_DIR:-/root/rose-ash}" # worktree that has node_modules + chromium +USER="admin" +PASS="picker-check-pw" +SECRET="picker-check-secret" +PDIR=$(mktemp -d) +JAR=$(mktemp) +SPEC_SRC="lib/host/playwright/relate-picker.spec.js" +SPEC_DST="$PW_DIR/tests/playwright/_picker-check.spec.js" +SERVE_LOG=$(mktemp) + +cleanup() { + [ -n "${SVPID:-}" ] && kill "$SVPID" 2>/dev/null + # kill whatever is still bound to the port (serve.sh re-parents via `| exec`) + local pid + pid=$(ss -lptn "sport = :$PORT" 2>/dev/null | grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2) + [ -n "$pid" ] && kill "$pid" 2>/dev/null + rm -f "$SPEC_DST" "$JAR" "$SERVE_LOG" + rm -rf "$PDIR" +} +trap cleanup EXIT + +echo "== starting ephemeral host server on :$PORT (persist=$PDIR) ==" +HOST_PORT="$PORT" SX_PERSIST_DIR="$PDIR" \ + SX_ADMIN_USER="$USER" SX_ADMIN_PASSWORD="$PASS" SX_SESSION_SECRET="$SECRET" \ + bash lib/host/serve.sh >"$SERVE_LOG" 2>&1 & +SVPID=$! + +for i in $(seq 1 60); do + curl -sf -o /dev/null "http://127.0.0.1:$PORT/health" 2>/dev/null && break + sleep 1 + [ "$i" = "60" ] && { echo "server never came up:"; cat "$SERVE_LOG"; exit 1; } +done +echo "== server up ==" + +echo "== seeding 1 host post + 25 candidates ==" +curl -s -c "$JAR" -o /dev/null -X POST "http://127.0.0.1:$PORT/login" \ + --data "username=$USER&password=$PASS" +curl -s -b "$JAR" -o /dev/null -X POST "http://127.0.0.1:$PORT/new" \ + --data 'title=Picker Host&sx_content=(p "host")&status=published' +for n in $(seq -w 1 25); do + curl -s -b "$JAR" -o /dev/null -X POST "http://127.0.0.1:$PORT/new" \ + --data "title=Picker Item $n&sx_content=(p \"item $n\")&status=published" +done +echo "== seeded ($(curl -s "http://127.0.0.1:$PORT/posts" | grep -o '"slug"' | wc -l) posts) ==" + +echo "== running Playwright ==" +cp "$ROOT/$SPEC_SRC" "$SPEC_DST" +cd "$PW_DIR" +SX_TEST_URL="http://127.0.0.1:$PORT" SX_ADMIN_USER="$USER" SX_ADMIN_PASSWORD="$PASS" \ + node_modules/.bin/playwright test _picker-check.spec.js --workers=1 \ + --config tests/playwright/playwright.config.js +RC=$? + +echo "== done (exit $RC) ==" +exit $RC diff --git a/lib/host/tests/blog.sx b/lib/host/tests/blog.sx index 35965c3c..dc59fe39 100644 --- a/lib/host/tests/blog.sx +++ b/lib/host/tests/blog.sx @@ -261,6 +261,10 @@ (let ((body (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options?q=beta"))))) (list (contains? body "Beta Post") (contains? body "Gamma Post"))) (list true false)) +(host-bl-test "relate-options filter url-decodes q (spaces)" + (let ((body (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options?q=Beta%20Post"))))) + (list (contains? body "Beta Post") (contains? body "Gamma Post"))) + (list true false)) (host-bl-test "relate-options excludes already-related candidates" (begin (host/blog-relate! "alpha-post" "beta-post")