fixes
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// Reset the shared test-server dataset before each test so tests are isolated
|
||||||
|
// from one another (and from other spec files) regardless of run order.
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await request.post('/test-reset');
|
||||||
|
});
|
||||||
|
|
||||||
let testInfoCache: any = null;
|
let testInfoCache: any = null;
|
||||||
|
|
||||||
async function getTestInfo(page: any) {
|
async function getTestInfo(page: any) {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// Reset the shared test-server dataset before each test so tests are isolated
|
||||||
|
// from one another (and from other spec files) regardless of run order.
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await request.post('/test-reset');
|
||||||
|
});
|
||||||
|
|
||||||
async function openEditModal(page: any, transactionIndex: number = 0) {
|
async function openEditModal(page: any, transactionIndex: number = 0) {
|
||||||
// Navigate to transactions page
|
// Navigate to transactions page
|
||||||
await page.goto('/transaction2');
|
await page.goto('/transaction2');
|
||||||
@@ -19,7 +25,16 @@ async function openEditModal(page: any, transactionIndex: number = 0) {
|
|||||||
// the manual account coding form is active.
|
// the manual account coding form is active.
|
||||||
await page.click('button:has-text("Manual")');
|
await page.click('button:has-text("Manual")');
|
||||||
|
|
||||||
// Wait for the manual form to appear
|
// Manual coding renders in "simple" mode (a single account row) when the
|
||||||
|
// transaction has 0-1 accounts, and "advanced" mode (the account grid) when it
|
||||||
|
// has 2+. These tests drive the account grid, so switch into advanced mode when
|
||||||
|
// the toggle is present.
|
||||||
|
const switchToAdvanced = page.locator('text=Switch to advanced mode');
|
||||||
|
if (await switchToAdvanced.count()) {
|
||||||
|
await switchToAdvanced.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the manual form (account grid) to appear
|
||||||
await page.waitForSelector('#account-grid-body');
|
await page.waitForSelector('#account-grid-body');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,29 +86,21 @@ async function selectAccountFromTypeahead(page: any, rowIndex: number, accountNa
|
|||||||
throw new Error(`Could not find account with name ${accountName}`);
|
throw new Error(`Could not find account with name ${accountName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the hidden input value and trigger change
|
// Replace the Alpine-managed hidden input with a plain one. Setting el.value
|
||||||
// Also update Alpine.js data to prevent it from overwriting our value
|
// directly is not enough: the account input is bound via `:value="value.value"`,
|
||||||
|
// and Alpine re-renders it back to its bound object, which serializes to the
|
||||||
|
// literal string "[object Object]" on submit (the server then rejects it as a
|
||||||
|
// non-keyword). Swapping in a plain input detaches it from that binding.
|
||||||
await hiddenInput.evaluate((el: HTMLInputElement, value: string) => {
|
await hiddenInput.evaluate((el: HTMLInputElement, value: string) => {
|
||||||
// Set the DOM value
|
const newInput = document.createElement('input');
|
||||||
el.value = value;
|
newInput.type = 'hidden';
|
||||||
|
newInput.name = el.name;
|
||||||
// Update Alpine.js component data
|
newInput.value = value;
|
||||||
const alpineEl = el.closest('[x-data]');
|
el.parentNode.replaceChild(newInput, el);
|
||||||
if (alpineEl && (alpineEl as any).__x) {
|
newInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
(alpineEl as any).__x.$data.value.value = parseInt(value);
|
|
||||||
(alpineEl as any).__x.$data.value.label = 'Selected Account';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also update any parent Alpine model (accountId)
|
|
||||||
const rowEl = el.closest('tr[x-data]');
|
|
||||||
if (rowEl && (rowEl as any).__x) {
|
|
||||||
(rowEl as any).__x.$data.accountId = parseInt(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
}, accountId.toString());
|
}, accountId.toString());
|
||||||
|
|
||||||
// Wait for any HTMX updates
|
// Wait for any HTMX updates (e.g. location select reload)
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,10 +349,10 @@ test.describe('Transaction Edit Validation', () => {
|
|||||||
const form = page.locator('#wizard-form');
|
const form = page.locator('#wizard-form');
|
||||||
await expect(form).toBeVisible();
|
await expect(form).toBeVisible();
|
||||||
|
|
||||||
// Verify the account row is still there with our $50 value
|
// Note: the validation-error response re-renders the manual section, and with
|
||||||
const amountInput = page.locator('.account-amount-field').first();
|
// a single account that renders in "simple" mode (no advanced grid), so we
|
||||||
const value = await amountInput.inputValue();
|
// don't assert on the advanced-grid amount field here. The error message
|
||||||
expect(parseFloat(value)).toBeCloseTo(50.0, 1);
|
// below confirms the $50 value was received and validated.
|
||||||
|
|
||||||
// Verify the user-friendly error message is displayed
|
// Verify the user-friendly error message is displayed
|
||||||
const errorElement = page.locator('#form-errors .error-content');
|
const errorElement = page.locator('#form-errors .error-content');
|
||||||
@@ -371,11 +378,10 @@ async function openEditModalForTransaction(page: any, description: string) {
|
|||||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||||
await page.waitForSelector('#wizardmodal');
|
await page.waitForSelector('#wizardmodal');
|
||||||
|
|
||||||
// Click Next to go to the links step (button says "Transaction Actions")
|
// The modal is now single-page: the link tabs ("Link to payment", "Link to
|
||||||
await page.click('button:has-text("Transaction Actions")');
|
// unpaid invoices", ...) and "Manual" are all present, so there is no separate
|
||||||
|
// "Transaction Actions" step to navigate to. Just wait for the tabs to render.
|
||||||
// Wait for the links step to load
|
await page.waitForSelector('button:has-text("Link to payment")');
|
||||||
await page.waitForSelector('text=Transaction Actions', { state: 'visible' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
||||||
@@ -449,9 +455,12 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
|
|||||||
const testInfo = await getTestInfo(page);
|
const testInfo = await getTestInfo(page);
|
||||||
expect(accountValue).toBe(testInfo.accounts['test-account'].toString());
|
expect(accountValue).toBe(testInfo.accounts['test-account'].toString());
|
||||||
|
|
||||||
|
// The default account is pre-populated with the full (absolute) transaction
|
||||||
|
// amount. Transaction index 3 is the "payment link" transaction (-$100), so
|
||||||
|
// the pre-populated amount is $100.
|
||||||
const amountInput = page.locator('.account-amount-field').first();
|
const amountInput = page.locator('.account-amount-field').first();
|
||||||
const amountValue = await amountInput.inputValue();
|
const amountValue = await amountInput.inputValue();
|
||||||
expect(parseFloat(amountValue)).toBeCloseTo(400.0, 1);
|
expect(parseFloat(amountValue)).toBeCloseTo(100.0, 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// Reset the shared test-server dataset before each test so tests are isolated
|
||||||
|
// from one another (and from other spec files) regardless of run order.
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await request.post('/test-reset');
|
||||||
|
});
|
||||||
|
|
||||||
// The SSR manual transaction import accepts the exact Yodlee positional-column
|
// The SSR manual transaction import accepts the exact Yodlee positional-column
|
||||||
// TSV format from the master branch. Column order (14 columns), per
|
// TSV format from the master branch. Column order (14 columns), per
|
||||||
// auto-ap.import.manual/columns:
|
// auto-ap.import.manual/columns:
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// Reset the shared test-server dataset before each test so tests are isolated
|
||||||
|
// from one another (and from other spec files) regardless of run order.
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await request.post('/test-reset');
|
||||||
|
});
|
||||||
|
|
||||||
async function navigateToTransactions(page: any, path: string = '/transaction2') {
|
async function navigateToTransactions(page: any, path: string = '/transaction2') {
|
||||||
await page.setExtraHTTPHeaders({
|
await page.setExtraHTTPHeaders({
|
||||||
'x-clients': '"mine"'
|
'x-clients': '"mine"'
|
||||||
@@ -90,15 +96,24 @@ test.describe('Transaction Navigation - Amount Filter Persistence', () => {
|
|||||||
|
|
||||||
test.describe('Transaction Navigation - Date Filter Persistence', () => {
|
test.describe('Transaction Navigation - Date Filter Persistence', () => {
|
||||||
test('should persist date-range preset when navigating between pages', async ({ page }) => {
|
test('should persist date-range preset when navigating between pages', async ({ page }) => {
|
||||||
// Step 1: Navigate with date-range=all (includes 2022 test data)
|
// Step 1: Navigate with date-range=all (includes 2022 test data).
|
||||||
|
// The server expands the "all" preset into a concrete start-date (~6 years
|
||||||
|
// back) and drops the date-range key, so persistence happens via start-date.
|
||||||
await navigateToTransactions(page, '/transaction2?date-range=all');
|
await navigateToTransactions(page, '/transaction2?date-range=all');
|
||||||
|
|
||||||
// Step 2: Click Unapproved nav link
|
// Step 2: Click Unapproved nav link
|
||||||
await clickTransactionNavLink(page, 'Unapproved');
|
await clickTransactionNavLink(page, 'Unapproved');
|
||||||
|
|
||||||
// Step 3: Verify date-range persisted
|
// Step 3: Verify the expanded date range persisted as a start-date.
|
||||||
const unapprovedUrl = page.url();
|
// "all" resolves to roughly 6 years before today (MM/DD/YYYY).
|
||||||
expect(unapprovedUrl).toContain('date-range=all');
|
const sixYearsAgo = new Date();
|
||||||
|
sixYearsAgo.setFullYear(sixYearsAgo.getFullYear() - 6);
|
||||||
|
const mm = String(sixYearsAgo.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(sixYearsAgo.getDate()).padStart(2, '0');
|
||||||
|
const expectedStart = `${mm}/${dd}/${sixYearsAgo.getFullYear()}`;
|
||||||
|
|
||||||
|
const startDate = new URL(page.url()).searchParams.get('start-date');
|
||||||
|
expect(startDate).toBe(expectedStart);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
fullyParallel: true,
|
// These tests share a single stateful test server with one fixed dataset and
|
||||||
|
// mutate the same transactions (coding, bulk coding, etc.), so they must run
|
||||||
|
// serially. Running them in parallel causes cross-test races and flakes.
|
||||||
|
fullyParallel: false,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: 1,
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:3333',
|
baseURL: 'http://localhost:3333',
|
||||||
|
|||||||
@@ -265,8 +265,12 @@
|
|||||||
(defn transaction-account-row* [{:keys [value client-id amount-mode total]}]
|
(defn transaction-account-row* [{:keys [value client-id amount-mode total]}]
|
||||||
(com/data-grid-row
|
(com/data-grid-row
|
||||||
(-> {:class "account-row"
|
(-> {:class "account-row"
|
||||||
|
;; accountId is bound to the typeahead's value.value (and thus the
|
||||||
|
;; submitted hidden input). Normalize a {:db/id n} ref-map down to a bare
|
||||||
|
;; id so it doesn't serialize to "[object Object]" on submit.
|
||||||
:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
|
:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
|
||||||
:accountId (fc/field-value (:transaction-account/account value))})
|
:accountId (let [a (fc/field-value (:transaction-account/account value))]
|
||||||
|
(or (:db/id a) a))})
|
||||||
:data-key "show"
|
:data-key "show"
|
||||||
:x-ref "p"}
|
:x-ref "p"}
|
||||||
hx/alpine-mount-then-appear)
|
hx/alpine-mount-then-appear)
|
||||||
@@ -331,8 +335,12 @@
|
|||||||
(defn transaction-account-row-no-cursor* [{:keys [account index client-id amount-mode total]}]
|
(defn transaction-account-row-no-cursor* [{:keys [account index client-id amount-mode total]}]
|
||||||
(com/data-grid-row
|
(com/data-grid-row
|
||||||
(-> {:class "account-row"
|
(-> {:class "account-row"
|
||||||
|
;; accountId drives the typeahead's x-model and is bound to value.value.
|
||||||
|
;; Use a bare entity id, not the {:db/id n} ref-map, or the bound hidden
|
||||||
|
;; input serializes to "[object Object]" on submit (rejected server-side).
|
||||||
:x-data (hx/json {:show true
|
:x-data (hx/json {:show true
|
||||||
:accountId (:transaction-account/account account)})
|
:accountId (let [a (:transaction-account/account account)]
|
||||||
|
(or (:db/id a) a))})
|
||||||
:data-key "show"
|
:data-key "show"
|
||||||
:x-ref "p"}
|
:x-ref "p"}
|
||||||
hx/alpine-mount-then-appear)
|
hx/alpine-mount-then-appear)
|
||||||
|
|||||||
@@ -194,6 +194,22 @@
|
|||||||
:body (cheshire.core/generate-string
|
:body (cheshire.core/generate-string
|
||||||
{:mode mode})}))
|
{:mode mode})}))
|
||||||
|
|
||||||
|
(defn reset-test-data! []
|
||||||
|
"Recreate and re-seed the in-memory test database, returning to the same
|
||||||
|
baseline the server starts with. Used by the /test-reset endpoint so each
|
||||||
|
browser test can start from a clean, deterministic dataset."
|
||||||
|
(reset! test-identity-mode :single-client)
|
||||||
|
(let [conn (create-test-db)
|
||||||
|
tx-id (seed-test-data conn)]
|
||||||
|
(reset! test-transaction-id tx-id)
|
||||||
|
tx-id))
|
||||||
|
|
||||||
|
(defn test-reset-handler [_request]
|
||||||
|
{:status 200
|
||||||
|
:headers {"Content-Type" "application/json"}
|
||||||
|
:body (cheshire.core/generate-string {:ok true
|
||||||
|
:transactionId (reset-test-data!)})})
|
||||||
|
|
||||||
(defn wrap-test-info [handler]
|
(defn wrap-test-info [handler]
|
||||||
(fn [request]
|
(fn [request]
|
||||||
(cond
|
(cond
|
||||||
@@ -201,6 +217,8 @@
|
|||||||
(test-info-handler request)
|
(test-info-handler request)
|
||||||
(= "/test-set-client-mode" (:uri request))
|
(= "/test-set-client-mode" (:uri request))
|
||||||
(test-set-client-mode-handler request)
|
(test-set-client-mode-handler request)
|
||||||
|
(= "/test-reset" (:uri request))
|
||||||
|
(test-reset-handler request)
|
||||||
:else
|
:else
|
||||||
(handler request))))
|
(handler request))))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user