Files
spring-boot-demo/thymeleaf-htmx/browser-check/check.mjs
T

117 lines
6.4 KiB
JavaScript

// Drives the running app in headless Chromium so the htmx behaviour claims are observed, not assumed.
// usage: node check.mjs http://localhost:18083
import { chromium } from 'playwright';
const base = process.argv[2] ?? 'http://localhost:18083';
const out = [];
const log = (s) => { out.push(s); console.log(s); };
const rows = (page) => page.locator('#contact-table tbody tr[id]');
const names = async (page) => (await rows(page).locator('td:first-child').allTextContents()).map(s => s.trim());
const browser = await chromium.launch();
const ctx = await browser.newContext();
const page = await ctx.newPage();
const requests = [];
page.on('request', r => { if (r.url().startsWith(base) && !r.url().includes('/webjars/') && !r.url().endsWith('.css'))
requests.push(`${r.method()} ${r.url().replace(base, '')} HX-Request=${r.headers()['hx-request'] ?? '-'} HX-History-Restore-Request=${r.headers()['hx-history-restore-request'] ?? '-'}`); });
log('== 1. load, then search without a page reload');
await page.goto(base + '/contacts');
await page.evaluate(() => { window.__marker = 'same-document'; });
log('htmx loaded: ' + await page.evaluate(() => typeof htmx + ' ' + htmx.version));
log('rows on first page: ' + (await rows(page).count()));
await page.fill('input[name=q]', 'kamat');
await page.waitForFunction(() => document.querySelectorAll('#contact-table tbody tr[id]').length === 1);
log('after typing "kamat": ' + JSON.stringify(await names(page)));
log('address bar: ' + page.url().replace(base, ''));
log('elements with id contact-table: ' + await page.locator('#contact-table').count());
log('document survived (no reload): ' + (await page.evaluate(() => window.__marker)));
log('\n== 2. pagination + back button');
await page.fill('input[name=q]', '');
await page.waitForFunction(() => document.querySelector('.pager span')?.textContent.includes('23 contacts'));
// no history snapshots: Back is a cache miss, so htmx must ask the server for the whole page
await page.evaluate(() => { htmx.config.historyCacheSize = 0; localStorage.removeItem('htmx-history-cache'); });
await page.click('text=Next');
await page.waitForFunction(() => document.querySelector('.pager span')?.textContent.startsWith('Page 2'));
log('page 2 names: ' + JSON.stringify(await names(page)));
log('address bar: ' + page.url().replace(base, ''));
const mark = requests.length;
await page.goBack();
await page.waitForFunction(() => document.querySelector('.pager span')?.textContent.startsWith('Page 1'));
log('Back request: ' + requests.slice(mark).join(' | '));
log('after Back: h1=' + JSON.stringify(await page.locator('h1').textContent()) + ', pager=' + JSON.stringify(await page.locator('.pager span').textContent()));
log('\n== 3. inline edit with a validation error');
const first = rows(page).first();
await first.locator('text=Edit').click();
await page.waitForSelector('tr.editing');
await page.fill('tr.editing input[name=name]', '');
await page.fill('tr.editing input[name=email]', 'nope');
await page.click('tr.editing >> text=Save');
await page.waitForSelector('tr.editing small.error');
log('row still editing, errors: ' + JSON.stringify(await page.locator('tr.editing small.error').allTextContents()));
await page.fill('tr.editing input[name=name]', 'Aarav K.');
await page.fill('tr.editing input[name=email]', '[email protected]');
await page.click('tr.editing >> text=Save');
await page.waitForSelector('tr:not(.editing) td:text-is("Aarav K.")');
log('saved, row is read-only again: ' + JSON.stringify(await names(page)));
log('\n== 4. delete with confirm');
const beforeDelete = requests.length;
page.once('dialog', d => { log('confirm dialog: ' + JSON.stringify(d.message())); d.accept(); });
await rows(page).first().locator('text=Delete').click();
await page.waitForFunction(() => document.querySelector('.pager span')?.textContent.includes('22 contacts'));
log('pager after delete: ' + JSON.stringify(await page.locator('.pager span').textContent()));
await page.waitForTimeout(500);
log('requests caused by one delete: ' + JSON.stringify(requests.slice(beforeDelete)));
log('\n== 5. create form: 422 re-render, then success');
await page.goto(base + '/contacts/new');
await page.fill('input[name=name]', '');
await page.fill('input[name=email]', 'x');
await page.fill('input[name=role]', 'Tester');
await page.click('button[type=submit]');
await page.waitForSelector('small.error');
log('errors shown in place: ' + JSON.stringify(await page.locator('small.error').allTextContents()));
log('still on: ' + page.url().replace(base, '') + ', typed email kept: ' + await page.inputValue('input[name=email]'));
await page.fill('input[name=name]', 'Zoya Khan');
await page.fill('input[name=email]', '[email protected]');
await page.click('button[type=submit]');
await page.waitForURL(base + '/contacts');
log('HX-Redirect landed on: ' + page.url().replace(base, ''));
log('\n== 6. the two status-code traps, observed');
// (a) a 204 answer to DELETE: htmx 2 does not swap it, the row stays
await page.route('**/contacts/*', async route => {
if (route.request().method() === 'DELETE') return route.fulfill({ status: 204 });
return route.continue();
});
const before = await rows(page).count();
page.once('dialog', d => d.accept());
await rows(page).first().locator('text=Delete').click();
await page.waitForTimeout(800);
log(`DELETE answered 204: rows before=${before}, after=${await rows(page).count()} (row not removed)`);
await page.unroute('**/contacts/*');
// (b) the same 422 without the htmx-config meta tag
await page.route('**/contacts/*/edit', route => route.continue());
await page.route('**/contacts', async route => {
if (route.request().method() !== 'GET' || route.request().headers()['hx-request']) return route.continue();
const resp = await route.fetch();
const html = (await resp.text()).replace(/<meta name="htmx-config"[\s\S]*?>/, '');
return route.fulfill({ response: resp, body: html });
});
await page.goto(base + '/contacts');
log('htmx-config meta present: ' + (await page.locator('meta[name=htmx-config]').count()));
await rows(page).first().locator('text=Edit').click();
await page.waitForSelector('tr.editing');
await page.fill('tr.editing input[name=name]', '');
await page.click('tr.editing >> text=Save');
await page.waitForTimeout(800);
log('Save answered 422 without the meta tag: errors visible = ' + (await page.locator('tr.editing small.error').count()));
log('\n== requests the browser made (excluding static files)');
for (const r of requests) log(r);
await browser.close();