// 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]', 'aarav.k@example.com'); 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]', 'zoya@example.com'); 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(//, ''); 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();