Files
rose-ash/tests/playwright/hypermedia-handlers.spec.js
giles 8027f51ef3 Playwright tests for all 27 hypermedia handler examples
Comprehensive interaction tests covering GET handlers (click-to-load,
tabs, polling, search, filter, keyboard, lazy-load), POST handlers
(form-submission, inline-validation, reset), mutation handlers
(delete-row, edit-row, inline-edit, bulk-update, put-patch), and
special handlers (dialogs, oob-swaps, animations, progress-bar, retry,
json-encoding, vals-and-headers).

Tests use trackErrors + waitForSxReady. Will pass once server is
restarted with request primitives and handler dispatch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:59:36 +00:00

331 lines
12 KiB
JavaScript

// @ts-check
/**
* Hypermedia handler integration tests.
* Verifies all example handlers respond correctly via the OCaml HTTP server.
* Requires server running with request primitives (now, state-get, request-form, etc.)
*/
const { test, expect } = require('playwright/test');
const { loadPage, waitForSxReady, trackErrors, BASE_URL } = require('./helpers');
// Helper: wait for an sx-get/sx-post response to arrive
async function waitForSwap(page, target, timeout = 5000) {
await page.waitForFunction(
(sel) => {
const el = document.querySelector(sel);
return el && el.innerHTML.trim().length > 0;
},
target,
{ timeout }
);
}
// ===========================================================================
// GET handlers — click/load interactions
// ===========================================================================
test.describe('GET handlers', () => {
let t;
test.beforeEach(({ page }) => { t = trackErrors(page); });
test.afterEach(() => { expect(t.errors()).toEqual([]); });
test('click-to-load: button fetches content', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.click-to-load)))');
const btn = page.locator('button:has-text("Load")').first();
if (await btn.count() > 0) {
await btn.click();
await page.waitForTimeout(2000);
// Should have received content from handler
const root = page.locator('#sx-root');
const text = await root.textContent();
expect(text.length).toBeGreaterThan(10);
}
});
test('tabs: clicking tab loads content', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.tabs)))');
const tabs = page.locator('[sx-get*="tab"]');
if (await tabs.count() >= 2) {
await tabs.nth(1).click();
await page.waitForTimeout(1500);
const root = page.locator('#sx-root');
const text = await root.textContent();
expect(text).not.toContain('classpx');
}
});
test('polling: content updates automatically', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.polling)))');
// Wait for at least one poll cycle
await page.waitForTimeout(3000);
const root = page.locator('#sx-root');
const text = await root.textContent();
expect(text.length).toBeGreaterThan(5);
});
test('lazy-loading: content appears without interaction', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.lazy-loading)))');
await page.waitForTimeout(2000);
const root = page.locator('#sx-root');
const text = await root.textContent();
expect(text.length).toBeGreaterThan(10);
});
test('active-search: typing triggers search', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.active-search)))');
const input = page.locator('input[placeholder*="earch"], input[name="q"]').first();
if (await input.count() > 0) {
await input.fill('python');
await page.waitForTimeout(2000);
const results = page.locator('#search-results, [id*="result"]').first();
if (await results.count() > 0) {
const text = await results.textContent();
expect(text.toLowerCase()).toContain('python');
}
}
});
test('select-filter: dropdown filters content', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.select-filter)))');
const select = page.locator('select').first();
if (await select.count() > 0) {
const options = await select.locator('option').allTextContents();
if (options.length >= 2) {
await select.selectOption({ index: 1 });
await page.waitForTimeout(1500);
}
}
});
test('keyboard-shortcuts: key press triggers action', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.keyboard-shortcuts)))');
await page.keyboard.press('s');
await page.waitForTimeout(1000);
const root = page.locator('#sx-root');
const text = await root.textContent();
expect(text.length).toBeGreaterThan(5);
});
test('loading-states: shows indicator during load', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.loading-states)))');
const btn = page.locator('button').first();
if (await btn.count() > 0) {
await btn.click();
// Should show loading state briefly then result
await page.waitForTimeout(3000);
}
});
test('infinite-scroll: loads more on scroll', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.infinite-scroll)))');
const rows = await page.locator('tr, li').count();
expect(rows).toBeGreaterThan(0);
});
test('value-select: selecting shows values', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.value-select)))');
const select = page.locator('select').first();
if (await select.count() > 0) {
await select.selectOption({ index: 1 });
await page.waitForTimeout(1500);
}
});
});
// ===========================================================================
// POST handlers — form submissions
// ===========================================================================
test.describe('POST handlers', () => {
let t;
test.beforeEach(({ page }) => { t = trackErrors(page); });
test.afterEach(() => { expect(t.errors()).toEqual([]); });
test('form-submission: submitting form shows result', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.form-submission)))');
const form = page.locator('form').first();
const inputs = form.locator('input[type="text"], input:not([type])');
if (await inputs.count() > 0) {
await inputs.first().fill('test-value');
}
const submit = form.locator('button[type="submit"], button').first();
await submit.click();
await page.waitForTimeout(2000);
const root = page.locator('#sx-root');
const text = await root.textContent();
expect(text).not.toContain('classpx');
});
test('inline-validation: invalid email shows error', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.inline-validation)))');
const input = page.locator('input[type="email"], input[name*="email"]').first();
if (await input.count() > 0) {
await input.fill('not-an-email');
await input.blur();
await page.waitForTimeout(1500);
}
});
test('reset-on-submit: form resets after submission', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.reset-on-submit)))');
const form = page.locator('form').first();
if (await form.count() > 0) {
const inputs = form.locator('input');
if (await inputs.count() > 0) {
await inputs.first().fill('test');
}
const btn = form.locator('button').first();
await btn.click();
await page.waitForTimeout(2000);
}
});
});
// ===========================================================================
// DELETE/PUT handlers
// ===========================================================================
test.describe('Mutation handlers', () => {
let t;
test.beforeEach(({ page }) => { t = trackErrors(page); });
test.afterEach(() => { expect(t.errors()).toEqual([]); });
test('delete-row: clicking delete removes row', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.delete-row)))');
const rowsBefore = await page.locator('table tbody tr').count();
const deleteBtn = page.locator('button:has-text("Delete")').first();
if (await deleteBtn.count() > 0 && rowsBefore > 0) {
await deleteBtn.click();
await page.waitForTimeout(2000);
const rowsAfter = await page.locator('table tbody tr').count();
// Row count should decrease or stay same (if swap replaces)
expect(rowsAfter).toBeLessThanOrEqual(rowsBefore);
}
});
test('edit-row: clicking edit shows form', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.edit-row)))');
const editBtn = page.locator('button:has-text("Edit")').first();
if (await editBtn.count() > 0) {
await editBtn.click();
await page.waitForTimeout(1500);
// Should show an edit form (inputs)
const root = page.locator('#sx-root');
const text = await root.textContent();
expect(text).not.toContain('classpx');
}
});
test('inline-edit: edit and save round-trip', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.inline-edit)))');
const editLink = page.locator('a:has-text("Edit"), button:has-text("Edit")').first();
if (await editLink.count() > 0) {
await editLink.click();
await page.waitForTimeout(1500);
}
});
test('bulk-update: select and deactivate', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.bulk-update)))');
const checkbox = page.locator('input[type="checkbox"]').first();
if (await checkbox.count() > 0) {
await checkbox.check();
const btn = page.locator('button:has-text("Deactivate")').first();
if (await btn.count() > 0) {
await btn.click();
await page.waitForTimeout(2000);
const table = page.locator('table').first();
const text = await table.textContent();
expect(text).not.toContain('classpx');
}
}
});
test('put-patch: form submits with PUT method', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.put-patch)))');
const root = page.locator('#sx-root');
const text = await root.textContent();
expect(text.length).toBeGreaterThan(5);
});
});
// ===========================================================================
// Special handlers — dialogs, OOB, animations, progress
// ===========================================================================
test.describe('Special handlers', () => {
let t;
test.beforeEach(({ page }) => { t = trackErrors(page); });
test.afterEach(() => { expect(t.errors()).toEqual([]); });
test('dialogs: open and close dialog', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.dialogs)))');
const openBtn = page.locator('button').first();
if (await openBtn.count() > 0) {
await openBtn.click();
await page.waitForTimeout(1000);
}
});
test('oob-swaps: response updates multiple targets', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.oob-swaps)))');
const btn = page.locator('button').first();
if (await btn.count() > 0) {
await btn.click();
await page.waitForTimeout(2000);
}
});
test('animations: swap includes animation', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.animations)))');
const btn = page.locator('button').first();
if (await btn.count() > 0) {
await btn.click();
await page.waitForTimeout(1500);
}
});
test('progress-bar: start shows progress', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.progress-bar)))');
const startBtn = page.locator('button:has-text("Start")').first();
if (await startBtn.count() > 0) {
await startBtn.click();
await page.waitForTimeout(3000);
}
});
test('swap-positions: position swap works', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.swap-positions)))');
const root = page.locator('#sx-root');
const text = await root.textContent();
expect(text.length).toBeGreaterThan(5);
});
test('sync-replace: synchronized replacement works', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.sync-replace)))');
const root = page.locator('#sx-root');
const text = await root.textContent();
expect(text.length).toBeGreaterThan(5);
});
test('retry: retry on failure works', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.retry)))');
const root = page.locator('#sx-root');
const text = await root.textContent();
expect(text.length).toBeGreaterThan(5);
});
test('json-encoding: JSON request/response works', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.json-encoding)))');
const root = page.locator('#sx-root');
const text = await root.textContent();
expect(text.length).toBeGreaterThan(5);
});
test('vals-and-headers: custom vals/headers sent', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.vals-and-headers)))');
const root = page.locator('#sx-root');
const text = await root.textContent();
expect(text.length).toBeGreaterThan(5);
});
});