# Instructions - Following Playwright test failed. - Explain why, be concise, respect Playwright best practices. - Provide a snippet of code with the fix, if possible. # Test info - Name: src/manager/tests/e2e/booking/booking-adjust-move-in-date.spec.ts >> auto-charges the first invoice when the adjusted move-in date activates the booking - Location: src/manager/tests/e2e/booking/booking-adjust-move-in-date.spec.ts:102:5 # Error details ``` Error: expect(locator).toHaveCount(expected) failed Locator: getByTestId('app-page').getByTestId('app-page-content').locator('[data-test-id=app-details-card]:has([data-test-id=title]:has-text("Invoices"))').locator('.ui-data-grid').locator('[data-test-id=ui-data-grid-table]:not(.disabled):not(.loading):not(.fetching)').getByTestId('ui-data-grid-table-with-data').locator('tr') Expected: 1 Received: 0 Timeout: 60000ms Call log: - Expect "toHaveCount" with timeout 60000ms - waiting for getByTestId('app-page').getByTestId('app-page-content').locator('[data-test-id=app-details-card]:has([data-test-id=title]:has-text("Invoices"))').locator('.ui-data-grid').locator('[data-test-id=ui-data-grid-table]:not(.disabled):not(.loading):not(.fetching)').getByTestId('ui-data-grid-table-with-data').locator('tr') 123 × locator resolved to 0 elements - unexpected value "0" ``` # Page snapshot ```yaml - generic [active] [ref=e1]: - generic [ref=e6]: - navigation [ref=e7]: - generic [ref=e9]: - link [ref=e10] [cursor=pointer]: - /url: / - generic [ref=e11]: - generic [ref=e12]: KINNOVIS - generic [ref=e13]: Manager - button [ref=e15] [cursor=pointer] - list [ref=e19]: - generic [ref=e21]: Dashboard - link "Tasks" [ref=e23] [cursor=pointer]: - /url: /tasks - link "Facility maps" [ref=e30] [cursor=pointer]: - /url: /facility-map - generic [ref=e31]: 󰧾 - link "Analytics" [ref=e35] [cursor=pointer]: - /url: /dashboard - generic [ref=e36]: 󱖶 - generic [ref=e40]: Sales - link "Bookings" [ref=e42] [cursor=pointer]: - /url: /bookings - generic [ref=e43]: 󰇡 - link "Customers" [ref=e47] [cursor=pointer]: - /url: /customers - generic [ref=e48]: 󰀏 - link "Invoices" [ref=e52] [cursor=pointer]: - /url: /invoices - generic [ref=e53]: 󰷉 - link "Credit notes" [ref=e57] [cursor=pointer]: - /url: /credit-notes - link "Units" [ref=e64] [cursor=pointer]: - /url: /units - generic [ref=e65]: 󰍀 - generic [ref=e69]: Site management - link "Locations" [ref=e71] [cursor=pointer]: - /url: /locations - generic [ref=e72]: 󰟙 - link "Unit types" [ref=e76] [cursor=pointer]: - /url: /unit-types - generic [ref=e77]: 󰆧 - link "Protection Plans" [ref=e81] [cursor=pointer]: - /url: /insurances - generic [ref=e82]: 󰳌 - link "Deposits" [ref=e86] [cursor=pointer]: - /url: /deposits - generic [ref=e87]: 󱙆 - link "Products" [ref=e91] [cursor=pointer]: - /url: /products - generic [ref=e92]: 󰄑 - link "Discounts" [ref=e96] [cursor=pointer]: - /url: /discounts - generic [ref=e97]: 󰓼 - generic [ref=e101]: - option "Emails" [ref=e102] [cursor=pointer]: - generic [ref=e103]: 󰻨 - generic [ref=e107]: 󰅀 - text: 󱡰 󰁥 - link "Document templates" [ref=e111] [cursor=pointer]: - /url: /document-templates - generic [ref=e112]: 󰷉 - generic [ref=e116]: Admin - link "Integrations" [ref=e118] [cursor=pointer]: - /url: /connected-apps - generic [ref=e119]: 󱘖 - link "User & Roles" [ref=e123] [cursor=pointer]: - /url: /users - generic [ref=e124]: 󰭘 - generic [ref=e128]: - option "Booking Portal" [ref=e129] [cursor=pointer]: - generic [ref=e130]: 󱃁 - generic [ref=e134]: 󰅀 - text: 󰖟 󰟙 - generic [ref=e138]: - option "JaneAI" [ref=e139] [cursor=pointer]: - generic [ref=e140]: 󱙺 - generic [ref=e144]: 󰅀 - text: 󱜹 - generic [ref=e148]: Feedback - link "Voting Portal" [ref=e150] [cursor=pointer]: - /url: /voting-portal - generic [ref=e151]: 󰔔 - generic [ref=e157]: - button "AS Andreas Stadler andreas.stadler@storeroom.at" [ref=e160] [cursor=pointer]: - generic [ref=e161]: AS - generic [ref=e164]: - generic [ref=e165]: Andreas Stadler - generic "andreas.stadler@storeroom.at" [ref=e166] - generic [ref=e167]: 󰇙 - generic: 󰗊 󰅀 󰷖 󰍃 - button [ref=e168] [cursor=pointer]: - generic [ref=e169]: 󰋽 - main [ref=e171]: - generic [ref=e173]: - generic [ref=e175]: - generic [ref=e176]: - link "Bookings" [ref=e177] [cursor=pointer]: - /url: /bookings - generic [ref=e179]: 󰅂 - generic [ref=e180]: Booking 1562-5279-08 - generic [ref=e181]: Active - button [ref=e186] [cursor=pointer]: - generic [ref=e187]: - text: Actions - generic [ref=e188]: 󱨉 - generic: 󱕂 - generic [ref=e189]: - generic [ref=e190]: - tablist [ref=e191]: - generic [ref=e193]: - tab "Details" [selected] [ref=e194] [cursor=pointer] - tab "Timeline" [ref=e196] [cursor=pointer] - separator [ref=e198] - generic [ref=e199]: - generic [ref=e200]: - generic [ref=e202]: - heading "General" [level=3] [ref=e206] - table [ref=e209]: - rowgroup [ref=e210]: - row [ref=e211]: - rowheader "Customer" [ref=e212] - cell [ref=e213]: - link "Owen McKenzie" [ref=e216] [cursor=pointer]: - /url: /customers/1724911746 - row [ref=e219]: - rowheader "Location" [ref=e220] - cell [ref=e221]: - link "Vienna South" [ref=e224] [cursor=pointer]: - /url: /locations/5000500050 - row [ref=e227]: - rowheader "Created at" [ref=e228] - cell "17 Aug 2026" [ref=e229] - row [ref=e232]: - rowheader "Move-in" [ref=e233] - cell "10 Aug 2026" [ref=e234] - row [ref=e237]: - rowheader "Move-out" [ref=e238] - cell "-" [ref=e239] - row [ref=e242]: - rowheader "Cancelled at" [ref=e243] - cell "-" [ref=e244] - row [ref=e247]: - rowheader "Deposit excl. VAT" [ref=e248] - cell "-" [ref=e249] - row [ref=e252]: - rowheader "Discount" [ref=e253] - cell "-" [ref=e254] - row [ref=e257]: - rowheader "Contract" [ref=e258] - cell "-" [ref=e259] - generic [ref=e262]: - generic [ref=e263]: - heading "Billing" [level=3] [ref=e267] - table [ref=e270]: - rowgroup [ref=e271]: - row [ref=e272]: - rowheader "Booking Plan" [ref=e273] - cell "Basic (1 week)" [ref=e274] - row [ref=e277]: - rowheader "Current period" [ref=e278] - cell "17 - 24 Aug 2026" [ref=e279] - row [ref=e282]: - rowheader "Amount excl. VAT" [ref=e283] - cell "€50.00" [ref=e284] - row [ref=e287]: - rowheader "Price adjustment" [ref=e288] - cell "-" [ref=e289] - row [ref=e292]: - rowheader "Next invoice" [ref=e293] - cell "24 Aug 2026" [ref=e294] - row [ref=e297]: - rowheader "Payment method" [ref=e298] - cell "•••• 4242 Card" [ref=e299]: - generic [ref=e305]: - generic [ref=e307]: •••• 4242 - generic [ref=e308]: Card - row [ref=e309]: - rowheader "Payment option" [ref=e310] - cell "Automatic payment through Stripe" [ref=e311] - generic [ref=e314]: - generic [ref=e316]: - heading "Notes" [level=3] [ref=e318] - link [ref=e320] [cursor=pointer]: - /url: /bookings/1562527908/edit/notes - generic [ref=e321]: 󰲶 - table [ref=e325]: - rowgroup [ref=e326]: - row [ref=e327]: - rowheader "Note" [ref=e328] - cell "-" [ref=e329] - generic [ref=e332]: - heading "Items" [level=3] [ref=e336] - generic [ref=e339]: - generic [ref=e340]: - generic [ref=e341]: - link "Intelligent Box t3rdQz" [ref=e342] [cursor=pointer]: - /url: /unit-types/1067016559 - text: / - link "Unit H1jb9B" [ref=e345] [cursor=pointer]: - /url: /units/892418914 - generic [ref=e348]: €50.00 - generic [ref=e349]: - generic [ref=e350]: - strong [ref=e351]: Total excl. VAT - strong [ref=e352]: €50.00 - generic [ref=e353]: - generic [ref=e354]: VAT - generic [ref=e355]: €8.50 - generic [ref=e356]: - strong [ref=e357]: Total incl. VAT - strong [ref=e358]: €58.50 - generic [ref=e359]: - heading "Invoices" [level=3] [ref=e363] - generic [ref=e367]: - generic [ref=e369]: - generic [ref=e371]: - button "Select all" [ref=e379] [cursor=pointer]: - generic [ref=e380]: 󰄬 - generic [ref=e383]: - button "Mark as paid" [disabled] [ref=e386]: - generic [ref=e387]: 󰗡 - button "SEPA XML actions" [disabled] [ref=e392]: - generic [ref=e393]: 󱨉 - generic: 󰗡 󰅚 󰗖 - generic [ref=e398]: - button [ref=e399] [cursor=pointer] - generic: 󰄲 󰄲 󰄲 󰄲 󰄲 󰄲 󰄲 󰄲 󰄲 󰄲 - table [ref=e405]: - rowgroup [ref=e406]: - row [ref=e407]: - columnheader [ref=e408]: - button "Number" [ref=e409] [cursor=pointer]: - generic [ref=e412]: 󰁝 - columnheader [ref=e415]: - button "Status" [ref=e416] [cursor=pointer]: - generic [ref=e419]: 󰁝 - columnheader [ref=e422]: - button "Payment option" [ref=e423] [cursor=pointer]: - generic [ref=e426]: 󰁝 - columnheader [ref=e429]: - button "Due date" [ref=e430] [cursor=pointer]: - generic [ref=e433]: 󰁝 - columnheader [ref=e436]: - button "Unit" [ref=e437] [cursor=pointer]: - generic [ref=e440]: 󰁝 - columnheader "Discount" [ref=e443] - columnheader [ref=e447]: - button "Total (incl. VAT)" [ref=e448] [cursor=pointer]: - generic [ref=e449]: - generic [ref=e450]: Total (incl. VAT) - generic [ref=e451]: 󰋽 - generic [ref=e453]: 󰁝 - columnheader [ref=e456]: - button "Amount due" [ref=e457] [cursor=pointer]: - generic [ref=e460]: 󰁝 - columnheader [ref=e463]: - button "Issue date" [ref=e464] [cursor=pointer]: - generic [ref=e467]: 󰁝 - columnheader "Actions" [ref=e470] - row [ref=e474]: - columnheader [ref=e475] - rowgroup [ref=e476]: - row [ref=e477]: - cell "No data yet" [ref=e478] - generic [ref=e482]: - generic [ref=e483]: - generic [ref=e484]: "Items per page:" - combobox [ref=e487]: - generic [ref=e489] [cursor=pointer]: - generic [ref=e490]: "10" - combobox "Items per page:": "10" - generic [ref=e492]: 󰍝 - generic [ref=e494]: 0-0 of 0 - generic [ref=e495]: - button [disabled]: - generic: 󰘀 - button [disabled]: - generic: 󰅁 - button [disabled]: - generic: 󰅂 - button [disabled]: - generic: 󰘁 - generic [ref=e496]: - heading "Payments" [level=3] [ref=e500] - generic [ref=e504]: - table [ref=e508]: - rowgroup [ref=e509]: - row [ref=e510]: - columnheader [ref=e511]: - button "Total" [ref=e512] [cursor=pointer]: - generic [ref=e515]: 󰁝 - columnheader [ref=e518]: - button "Status" [ref=e519] [cursor=pointer]: - generic [ref=e522]: 󰁝 - columnheader [ref=e525]: - button "Invoice no." [ref=e526] [cursor=pointer]: - generic [ref=e529]: 󰁝 - columnheader [ref=e532]: - button "Date" [ref=e533] [cursor=pointer]: - generic [ref=e536]: 󰁝 - columnheader [ref=e539]: - button "Payment method" [ref=e540] [cursor=pointer]: - generic [ref=e543]: 󰁝 - row [ref=e546]: - columnheader [ref=e547] - rowgroup [ref=e548]: - row [ref=e549]: - cell "No data yet" [ref=e550] - generic [ref=e554]: - generic [ref=e555]: - generic [ref=e556]: "Items per page:" - combobox [ref=e559]: - generic [ref=e561] [cursor=pointer]: - generic [ref=e562]: "10" - combobox "Items per page:": "10" - generic [ref=e564]: 󰍝 - generic [ref=e566]: 0-0 of 0 - generic [ref=e567]: - button [disabled]: - generic: 󰘀 - button [disabled]: - generic: 󰅁 - button [disabled]: - generic: 󰅂 - button [disabled]: - generic: 󰘁 - generic: - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip - tooltip ``` # Test source ```ts 1 | import { BookingItemsDetails, BookingPriceChangeDetails } from '@/manager/modules/booking/booking-types'; 2 | import { getBookingDepositInvoiceNumber } from '@/manager/modules/booking/booking-utils'; 3 | import { BookingDetailsItemsSection } from '@/manager/modules/booking/components/BookingDetailsItemsSection'; 4 | import { BookingDetailsPage } from '@/manager/modules/booking/views/BookingDetailsPage'; 5 | import { 6 | bookingPaymentTableColumnTestIds, 7 | invoiceTableColumnTestIds, 8 | } from '@/manager/modules/ui/data-table/data/data-table-column-test-ids'; 9 | import { expectSingleLocatorToHaveText } from '@/manager/shared/utils/expect-utils'; 10 | import { formatCurrency, formatDate } from '@/shared/utils/formatters'; 11 | import { expect } from '@/shared/utils/matchers'; 12 | 13 | export async function verifyBookingDetailsItemsSection( 14 | section: BookingDetailsItemsSection, 15 | data: BookingItemsDetails, 16 | currency: string 17 | ): Promise { 18 | await expectSingleLocatorToHaveText(section.unitTypeLabel, data.unitType.name); 19 | await expectSingleLocatorToHaveText(section.unitLabel, data.unitType.unitName); 20 | await expectSingleLocatorToHaveText( 21 | section.unitTypePriceExclVat, 22 | formatCurrency(data.unitType.priceExclVat, currency) 23 | ); 24 | 25 | if (data.insurance) { 26 | await expectSingleLocatorToHaveText(section.insuranceLabel, data.insurance.name); 27 | await expectSingleLocatorToHaveText( 28 | section.insurancePriceExclVat, 29 | formatCurrency(data.insurance.priceExclVat, currency) 30 | ); 31 | } else { 32 | await expect(section.insuranceLabel).toBeHidden(); 33 | await expect(section.insurancePriceExclVat).toBeHidden(); 34 | } 35 | 36 | // The SPA renders recurring product rows in the order they were added to the booking (the API 37 | // orders by the booking_has_recurring_products pivot id — see orderByPivot('id')). That insertion 38 | // order matches data.recurringProducts, so iterate it as-is; sorting by product id is wrong because 39 | // parallel test setup assigns product ids in a non-deterministic order. The per-piece price line is 40 | // only shown for multi-piece items, so it is tracked with its own running index. 41 | const recurringProducts = data.recurringProducts; 42 | let perPieceIndex = 0; 43 | 44 | for (const [index, product] of recurringProducts.entries()) { 45 | // The label drops the "{quantity} x" prefix when only a single piece is booked. 46 | const label = product.quantity > 1 ? `${product.quantity} x ${product.name}` : product.name; 47 | await expectSingleLocatorToHaveText(section.recurringProductLabel.nth(index), label); 48 | await expectSingleLocatorToHaveText( 49 | section.recurringProductPriceExclVat.nth(index), 50 | formatCurrency(product.priceExclVat, currency) 51 | ); 52 | 53 | if (product.quantity > 1) { 54 | await expectSingleLocatorToHaveText( 55 | section.recurringProductPricePerPiece.nth(perPieceIndex), 56 | `${formatCurrency(product.perPieceExclVat, currency)} per piece` 57 | ); 58 | perPieceIndex++; 59 | } 60 | } 61 | 62 | await expectSingleLocatorToHaveText(section.totalExclVatValue, formatCurrency(data.totals.exclVat, currency)); 63 | await expectSingleLocatorToHaveText(section.vatValue, formatCurrency(data.totals.vat, currency)); 64 | await expectSingleLocatorToHaveText(section.totalInclVatValue, formatCurrency(data.totals.inclVat, currency)); 65 | } 66 | 67 | // Invoices are issued by an asynchronous backend job after the booking is created, and the invoices 68 | // card polls for them on its own (~every 5s) — no reload needed. getRows() only resolves against a 69 | // settled (non-loading) grid, so toHaveCount retries across the card's poll cycles until the issued 70 | // invoices render. The window is deliberately bounded: if issuance doesn't land within it (e.g. the 71 | // billing job lagging under load), that is a real backend-latency signal — don't paper over it by 72 | // bumping the timeout. 73 | export async function verifyBookingInvoiceCount(detailsPage: BookingDetailsPage, expected: number): Promise { > 74 | await expect(detailsPage.invoicesCard.dataTable.getRows()).toHaveCount(expected, { timeout: 60_000 }); | ^ Error: expect(locator).toHaveCount(expected) failed 75 | } 76 | 77 | // Payments are recorded by the same asynchronous backend job that issues the invoices, and the payments 78 | // card polls for them on its own (~every 5s) — no reload needed. getRows() only resolves against a 79 | // settled (non-loading) grid, so toHaveCount retries across the card's poll cycles until the payments 80 | // render. The window is deliberately bounded: if the payment doesn't land within it (e.g. the billing 81 | // job lagging under load), that is a real backend-latency signal — don't paper over it by bumping the timeout. 82 | export async function verifyBookingPaymentCount(detailsPage: BookingDetailsPage, expected: number): Promise { 83 | await expect(detailsPage.paymentsCard.dataTable.getRows()).toHaveCount(expected, { timeout: 60_000 }); 84 | } 85 | 86 | // The whole price adjustment scene in the billing card: chip, new subscription amount, targets, and the 87 | // snapped apply-from date. The SPA polls the booking while has_pending_changes is set, so the section 88 | // appears without a reload once the backend has persisted the change. 89 | export async function verifyBookingPriceChangeSection( 90 | detailsPage: BookingDetailsPage, 91 | expected: BookingPriceChangeDetails, 92 | currency: string, 93 | timezone: string 94 | ): Promise { 95 | const section = detailsPage.billingCard.priceChangeSection; 96 | 97 | await expectSingleLocatorToHaveText(section.chip, expected.chip); 98 | await expectSingleLocatorToHaveText( 99 | section.newAmount, 100 | `New amount excl. VAT: ${formatCurrency(expected.newAmountExclVat, currency)}` 101 | ); 102 | await expectSingleLocatorToHaveText(section.appliedOn, expected.appliedOn); 103 | await expectSingleLocatorToHaveText( 104 | section.appliedFrom, 105 | `applied from ${formatDate(expected.appliedFrom, timezone)}` 106 | ); 107 | } 108 | 109 | // Asserts one invoice row in full. Pass `numberColumnText` to locate the row in the number column — an 110 | // invoice number for a regular invoice, or the "Deposit" chip label for the deposit invoice (whose number 111 | // cell also holds the chip, so it can't be matched by number) — needed when a booking has several invoices. 112 | // Omit it for a booking with a single invoice, where it falls back to the first row. Covers every column 113 | // the row carries besides the number itself. `unit` is optional — omit it for an invoice not tied to a 114 | // unit (e.g. the upfront-deposit invoice), where the unit cell asserts empty. 115 | export async function verifyBookingInvoice( 116 | detailsPage: BookingDetailsPage, 117 | expected: { 118 | status: string; 119 | paymentOption: string; 120 | unit?: string; 121 | amount: string; 122 | amountDue: string; 123 | issueDate: string; 124 | dueDate: string; 125 | }, 126 | numberColumnText?: string 127 | ): Promise { 128 | const invoices = detailsPage.invoicesCard.dataTable; 129 | const cell = (columnId: string) => 130 | numberColumnText 131 | ? invoices.getRowColumnBy([{ columnId: invoiceTableColumnTestIds.id, text: numberColumnText }], columnId) 132 | : invoices.getRowColumnByIndex(0, columnId); 133 | 134 | await expectSingleLocatorToHaveText(cell(invoiceTableColumnTestIds.status), expected.status); 135 | await expectSingleLocatorToHaveText(cell(invoiceTableColumnTestIds.paymentOption), expected.paymentOption); 136 | await expectSingleLocatorToHaveText(cell(invoiceTableColumnTestIds.unit), expected.unit); 137 | await expectSingleLocatorToHaveText(cell(invoiceTableColumnTestIds.amount), expected.amount); 138 | await expectSingleLocatorToHaveText(cell(invoiceTableColumnTestIds.amountDue), expected.amountDue); 139 | await expectSingleLocatorToHaveText(cell(invoiceTableColumnTestIds.issueDate), expected.issueDate); 140 | await expectSingleLocatorToHaveText(cell(invoiceTableColumnTestIds.dueDate), expected.dueDate); 141 | } 142 | 143 | // A booking has one payment row per invoice in the payments grid, keyed by invoice number. Pass an 144 | // invoiceNumber to scope the check to that invoice's payment — needed when a booking has several invoices 145 | // (e.g. an upfront deposit's invoice alongside the first-period one), each with its own payment; omit it 146 | // for a booking with a single payment. Asserts the row's gross amount, status, payment method, and date. 147 | // Omit paymentMethod for an unpaid payment (e.g. a manual invoice's "Not paid yet" row): no method has 148 | // been charged yet, so the cell is empty and the assertion checks it renders blank. 149 | export async function verifyBookingPayment( 150 | detailsPage: BookingDetailsPage, 151 | expected: { amount: string; status: string; paymentMethod?: string; date: string }, 152 | invoiceNumber?: string 153 | ): Promise { 154 | const payments = detailsPage.paymentsCard.dataTable; 155 | const cell = (columnId: string) => 156 | invoiceNumber 157 | ? payments.getRowColumnBy( 158 | [{ columnId: bookingPaymentTableColumnTestIds.invoiceId, text: invoiceNumber }], 159 | columnId 160 | ) 161 | : payments.getRowColumnByIndex(0, columnId); 162 | 163 | await expectSingleLocatorToHaveText(cell(bookingPaymentTableColumnTestIds.amountInclVat), expected.amount); 164 | await expectSingleLocatorToHaveText(cell(bookingPaymentTableColumnTestIds.status), expected.status); 165 | await expectSingleLocatorToHaveText(cell(bookingPaymentTableColumnTestIds.paymentMethod), expected.paymentMethod); 166 | await expectSingleLocatorToHaveText(cell(bookingPaymentTableColumnTestIds.date), expected.date); 167 | } 168 | 169 | // The deposit field renders its amount as a link to the deposit invoice, but the link is only hydrated 170 | // on a fresh load, not right after the booking is created. So grep the invoice's number from the grid, 171 | // reload to mount the link, then assert the field reads "{depositAmount} ({number})" with the number 172 | // rendered as the invoice link. 173 | export async function verifyBookingDepositInvoiceLink( 174 | detailsPage: BookingDetailsPage, ```