import { test, expect } from '@playwright/test'; // These tests cover the "post the whole form, hx-select what to swap" behaviour // on the transaction edit page. Each edit hits its own route, the server // re-renders the entire form, and the client selects what to swap back -- with // no out-of-band swaps and no morph extension: // - discrete changes (vendor, account, location, mode, add/remove row) swap // all of #wizard-form (the active action/tab round-trips through the form, // so it survives the swap); // - typed fields never swap the input the user is in -- the amount field swaps // only the #account-totals tbody (a sibling of the input rows), and the memo // posts with hx-swap=none. // Because the active input is never part of a swapped region, focus and caret // survive a plain swap. // Collect any uncaught page errors or console errors so a swap that throws // (e.g. a tooltip callback dereferencing a stale $refs) fails the test loudly. function trackErrors(page: any): string[] { const errors: string[] = []; page.on('pageerror', (e: any) => errors.push('pageerror: ' + e.message)); page.on('console', (m: any) => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); return errors; } async function openManualAdvanced(page: any, transactionIndex = 0) { await page.goto('/transaction2'); await page.waitForSelector('table tbody tr'); await page .locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]') .nth(transactionIndex) .click(); await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); await page.waitForSelector('#wizardmodal'); await page.click('button:has-text("Manual")'); // First transaction has no accounts so it opens in "simple" mode. Switch to // advanced mode (a whole-form swap) so the account grid is present. const advancedLink = page.locator('a:has-text("Switch to advanced mode")'); if (await advancedLink.count()) { await advancedLink.first().click(); await page.waitForSelector('#account-grid-body'); } } // Drives the vendor typeahead like a user: open the dropdown, inject a result // (Solr is unavailable in tests), click it, and wait for the whole-form swap. async function selectVendor(page: any, vendorId: number, label: string) { const vendor = page .locator('div[hx-vals*="vendor-changed"]') .first() .locator('div.relative[x-data]') .first(); await vendor.locator('a[x-ref="input"]').click(); const search = page.locator('[data-tippy-root] input[x-model="search"]').first(); await search.waitFor({ state: 'visible' }); await search.fill('xx'); await vendor.evaluate((el: HTMLElement, opt: { id: number; label: string }) => { (window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }]; }, { id: vendorId, label }); const swap = page.waitForResponse( (r: any) => r.url().includes('edit-form-changed') && r.request().method() === 'POST' && r.status() === 200 ); await page.locator('[data-tippy-root] a', { hasText: label }).first().click(); await swap; await page.waitForTimeout(400); } // Removes every existing account row (each remove is its own whole-form swap), so a // test starts from a known-empty state regardless of what earlier tests saved // onto the shared transaction. async function clearAccounts(page: any) { // eslint-disable-next-line no-constant-condition while (true) { const removeButtons = page.locator('#account-grid-body .account-remove-action'); const count = await removeButtons.count(); if (count === 0) break; await removeButtons.first().click(); await expect .poll(async () => page.locator('#account-grid-body .account-remove-action').count()) .toBeLessThan(count); } } test.describe.configure({ mode: 'serial' }); test.describe('Transaction Edit whole-form swap', () => { test('whole-form swaps (toggle mode, add account) do not throw', async ({ page }) => { const errors = trackErrors(page); await openManualAdvanced(page, 0); // Add an account row -- another whole-form swap. await page .locator('#account-grid-body') .locator('button:has-text("New account"), a:has-text("New account")') .first() .click(); await expect .poll(async () => page.locator('#account-grid-body tbody tr.account-row').count()) .toBeGreaterThan(0); // The form must survive the swap intact. await expect(page.locator('#wizard-form')).toHaveCount(1); expect(errors, errors.join('\n')).toEqual([]); }); test('keeps focus and typed value in the amount field across a swap', async ({ page }) => { const errors = trackErrors(page); await openManualAdvanced(page, 0); // Ensure exactly one account row exists. const rows = await page.locator('#account-grid-body tbody tr.account-row').count(); if (rows === 0) { await page .locator('#account-grid-body') .locator('button:has-text("New account"), a:has-text("New account")') .first() .click(); await expect .poll(async () => page.locator('#account-grid-body tbody tr.account-row').count()) .toBeGreaterThan(0); } const amount = page.locator('.account-amount-field').first(); await amount.waitFor(); // Type a clean value via the keyboard. Typing fires the field's htmx trigger // (keyup), which posts the whole form but swaps back only the #account-totals // tbody -- a sibling of this input's row, so the input is never replaced. It's // type=number (no text caret), so we assert focus + node identity + value. await amount.click(); await amount.press('Control+a'); const amountSwap = page.waitForResponse( (r: any) => r.url().includes('edit-form-changed') && r.request().method() === 'POST' && r.status() === 200 ); await amount.pressSequentially('150', { delay: 40 }); // Identify the live focused node (before the debounced swap lands) so we can // prove the *same* node survives. await page.evaluate(() => { (window as any).__focusedAmount = document.activeElement; }); await amountSwap; await page.waitForTimeout(300); const state = await page.evaluate(() => { const active = document.activeElement as HTMLInputElement; return { sameNode: active === (window as any).__focusedAmount, isAmountField: !!active && active.classList.contains('account-amount-field'), value: active ? active.value : null, }; }); // Focus must stay on the amount field after the swap... expect(state.isAmountField).toBe(true); // ...on the very same DOM node (the input is never part of the swapped region)... expect(state.sameNode).toBe(true); // ...with the value the user typed left intact. expect(state.value).toBe('150'); // The TOTAL must have recomputed server-side from the posted amount and been // applied via the #account-totals swap. await expect(page.locator('.account-total-row #total')).toContainText('150'); expect(errors, errors.join('\n')).toEqual([]); }); test('memo edits issue no request and keep their value/caret', async ({ page }) => { const errors = trackErrors(page); // Memo affects nothing else in the form, so editing it must NOT issue a // request at all -- its value just rides along in the form until save. let memoRequests = 0; page.on('request', (r: any) => { if (r.url().includes('edit-form-changed') && r.method() === 'POST') memoRequests++; }); await page.goto('/transaction2'); await page.waitForSelector('table tbody tr'); await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click(); await page.waitForSelector('#wizardmodal'); const memo = page.locator('#edit-memo'); await memo.waitFor(); // Clear any seeded memo text and type "hello". await memo.click(); await memo.press('Control+a'); await memo.pressSequentially('hello', { delay: 40 }); // Drop the caret in the middle and insert a char -> "heXllo", caret -> 3. await memo.evaluate((el: HTMLInputElement) => { el.focus(); el.setSelectionRange(2, 2); }); await page.evaluate(() => { (window as any).__focusedMemo = document.activeElement; }); await memo.press('X'); // Give the old debounce window a chance to (not) fire. await page.waitForTimeout(500); const state = await page.evaluate(() => { const active = document.activeElement as HTMLInputElement; return { sameNode: active === (window as any).__focusedMemo, id: active ? active.id : null, value: active ? active.value : null, caret: active ? active.selectionStart : null, }; }); // No request fired, and the value/caret are simply intact (nothing swapped). expect(memoRequests).toBe(0); expect(state.id).toBe('edit-memo'); expect(state.sameNode).toBe(true); expect(state.value).toBe('heXllo'); expect(state.caret).toBe(3); expect(errors, errors.join('\n')).toEqual([]); }); test('choosing an account from the typeahead does not throw and persists', async ({ page }) => { const errors = trackErrors(page); await openManualAdvanced(page, 0); // Start from a clean, empty account row so selecting the account actually // changes accountId (and fires the change-gated whole-form swap). await clearAccounts(page); await page .locator('#account-grid-body') .locator('button:has-text("New account"), a:has-text("New account")') .first() .click(); await expect .poll(async () => page.locator('#account-grid-body tbody tr.account-row').count()) .toBeGreaterThan(0); const row = page.locator('#account-grid-body tbody tr.account-row').first(); const typeahead = row.locator('div.relative[x-data]').first(); // Open the dropdown (tippy renders the popper into [data-tippy-root]). await typeahead.locator('a[x-ref="input"]').click(); const search = page.locator('[data-tippy-root] input[x-model="search"]').first(); await search.waitFor({ state: 'visible' }); // Account search is backed by Solr (unavailable in tests), so type under the // 3-char threshold and inject a clickable result into the typeahead state -- // the click handler, tippy.hide(), Alpine reactivity and the HTMX swap all run // exactly as in production. await search.fill('te'); const testInfo = await (await page.request.get('/test-info')).json(); const accountId: number = testInfo.accounts['test-account']; await typeahead.evaluate((el: HTMLElement, id: number) => { (window as any).Alpine.$data(el).elements = [{ value: id, label: 'Test Account' }]; }, accountId); // Clicking the result runs `value = element; tippy.hide(); ...` and dispatches // the change that fires the whole-form swap. const swap = page.waitForResponse( (r: any) => r.url().includes('edit-form-changed') && r.request().method() === 'POST' && r.status() === 200 ); await page.locator('[data-tippy-root] a', { hasText: 'Test Account' }).first().click(); await swap; await page.waitForTimeout(300); // The chosen account must survive the whole-form swap. const hidden = page .locator('#account-grid-body tbody tr.account-row') .first() .locator('input[type="hidden"][name*="transaction-account/account"]') .first(); await expect(hidden).toHaveValue(accountId.toString()); expect(errors, errors.join('\n')).toEqual([]); }); test('selecting a vendor populates its default account across the swap', async ({ page }) => { const errors = trackErrors(page); // Open the modal in simple mode (transaction 0 has no accounts). await page.goto('/transaction2'); await page.waitForSelector('table tbody tr'); await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click(); await page.waitForSelector('#wizardmodal'); await page.click('button:has-text("Manual")'); await page.waitForSelector('div[hx-vals*="vendor-changed"]'); const testInfo = await (await page.request.get('/test-info')).json(); const vendorId: number = testInfo.accounts.vendor; const defaultAccountId: number = testInfo.accounts['test-account']; // Drive the vendor typeahead like a user: open dropdown, inject a result // (Solr is unavailable in tests), click it. const vendor = page.locator('div[hx-vals*="vendor-changed"]').first().locator('div.relative[x-data]').first(); await vendor.locator('a[x-ref="input"]').click(); const search = page.locator('[data-tippy-root] input[x-model="search"]').first(); await search.waitFor({ state: 'visible' }); await search.fill('te'); await vendor.evaluate((el: HTMLElement, id: number) => { (window as any).Alpine.$data(el).elements = [{ value: id, label: 'Test Vendor' }]; }, vendorId); const swap = page.waitForResponse( (r: any) => r.url().includes('edit-form-changed') && r.request().method() === 'POST' && r.status() === 200 ); await page.locator('[data-tippy-root] a', { hasText: 'Test Vendor' }).first().click(); await swap; await page.waitForTimeout(400); // The vendor's default account must now be reflected in the account field. // Because the section is rebuilt fresh from the server (no preserved Alpine // state), the server-driven account value lands without any keying tricks. const accountHidden = page .locator('input[type="hidden"][name*="transaction-account/account"]') .first(); await expect(accountHidden).toHaveValue(defaultAccountId.toString()); // The displayed account label should resolve too. await expect(page.locator('span[x-text="value.label"]', { hasText: 'Test Account' })).toBeVisible(); expect(errors, errors.join('\n')).toEqual([]); }); test('changing the vendor a second time still updates it', async ({ page }) => { const errors = trackErrors(page); await page.goto('/transaction2'); await page.waitForSelector('table tbody tr'); await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click(); await page.waitForSelector('#wizardmodal'); await page.click('button:has-text("Manual")'); await page.waitForSelector('div[hx-vals*="vendor-changed"]'); const testInfo = await (await page.request.get('/test-info')).json(); const vendor1: number = testInfo.accounts.vendor; const vendor2: number = testInfo.accounts.vendor2; const account1: number = testInfo.accounts['test-account']; const account2: number = testInfo.accounts['second-account']; const vendorLabel = page .locator('div[hx-vals*="vendor-changed"] span[x-text="value.label"]') .first(); const accountHidden = page .locator('input[type="hidden"][name*="transaction-account/account"]') .first(); // First vendor. await selectVendor(page, vendor1, 'Test Vendor'); await expect(vendorLabel).toHaveText('Test Vendor'); await expect(accountHidden).toHaveValue(account1.toString()); // Second vendor -- the regression guard: the section (and its vendor // typeahead) is rebuilt fresh on every swap, so a second change still fires // its request and updates the default account. await selectVendor(page, vendor2, 'Second Vendor'); await expect(vendorLabel).toHaveText('Second Vendor'); await expect(accountHidden).toHaveValue(account2.toString()); // And back again, to be sure it keeps working. await selectVendor(page, vendor1, 'Test Vendor'); await expect(vendorLabel).toHaveText('Test Vendor'); await expect(accountHidden).toHaveValue(account1.toString()); expect(errors, errors.join('\n')).toEqual([]); }); });