The slug extractor after (api. scanned for the first ) but for nested URLs like (api.(delete.1)) it got "(delete.1" instead of "delete". Now handles nested parens: extracts handler name and injects path params into query string. Also strengthened the Playwright test to accept confirm dialogs and assert strict row count decrease. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
337 lines
13 KiB
JavaScript
337 lines
13 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)))');
|
|
// 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);
|
|
});
|
|
});
|