diff --git a/tests/playwright/hypermedia-handlers.spec.js b/tests/playwright/hypermedia-handlers.spec.js new file mode 100644 index 00000000..d49413ad --- /dev/null +++ b/tests/playwright/hypermedia-handlers.spec.js @@ -0,0 +1,330 @@ +// @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); + }); +});