// @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.pressSequentially('python', { delay: 50 }); 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)))'); // Wait for the intersect trigger to fire and load initial items await page.waitForTimeout(3000); const root = page.locator('#sx-root'); const text = await root.textContent(); expect(text.length).toBeGreaterThan(50); }); 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)))'); // Auto-accept confirm dialogs (sx-confirm triggers window.confirm) page.on('dialog', dialog => dialog.accept()); const rowsBefore = await page.locator('tbody#delete-rows tr').count(); expect(rowsBefore).toBeGreaterThan(0); const firstRowId = await page.locator('tbody#delete-rows tr').first().getAttribute('id'); const deleteBtn = page.locator('tbody#delete-rows tr:first-child button').first(); await deleteBtn.click(); await page.waitForTimeout(2000); const rowsAfter = await page.locator('tbody#delete-rows tr').count(); // Row count must strictly decrease expect(rowsAfter).toBe(rowsBefore - 1); // The specific row must be gone if (firstRowId) { await expect(page.locator(`#${firstRowId}`)).toHaveCount(0); } }); 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); }); });