|
| 1 | +import { test, expect } from '@playwright/test'; |
| 2 | + |
| 3 | +/** |
| 4 | + * Tier 0 live e2e: a `file` field on a master-detail child renders a real |
| 5 | + * UPLOAD control inside the inline line-item grid — not a degraded text input — |
| 6 | + * and the uploaded file persists on the line in the atomic /api/v1/batch |
| 7 | + * (objectui#2360). `showcase_invoice_line.receipt` is a `Field.file()`, so every |
| 8 | + * standard Invoice form's "Line Items" grid gets a per-row Receipt upload cell, |
| 9 | + * auto-derived from the data model (no columns config). |
| 10 | + * |
| 11 | + * This codifies the manual dogfood pass that shipped #2360: |
| 12 | + * • the Receipt column auto-derives into the grid (file fields are no longer |
| 13 | + * dropped from auto-columns); |
| 14 | + * • the cell is a genuine `input[type=file]` upload control (compact button + |
| 15 | + * removable chip), never a text `<Input>`; |
| 16 | + * • picking a file uploads it through the console's UploadProvider adapter and |
| 17 | + * shows a chip with the file name; |
| 18 | + * • submit carries the resolved file object ({ name, url, … }) on the line. |
| 19 | + * |
| 20 | + * Live-only (needs the storage service + a real backend); runs under |
| 21 | + * `pnpm test:e2e:live`, not the mocked PR e2e job. |
| 22 | + */ |
| 23 | + |
| 24 | +// A 1×1 PNG — smallest valid image payload for the upload round-trip. |
| 25 | +const PNG = Buffer.from( |
| 26 | + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==', |
| 27 | + 'base64', |
| 28 | +); |
| 29 | + |
| 30 | +test('inline line-item grid uploads a per-row file and persists it in the batch', async ({ page }) => { |
| 31 | + const batches: any[] = []; |
| 32 | + page.on('request', (r) => { |
| 33 | + if (r.method() === 'POST' && r.url().includes('/api/v1/batch')) { |
| 34 | + try { batches.push(r.postDataJSON()); } catch { /* ignore */ } |
| 35 | + } |
| 36 | + }); |
| 37 | + |
| 38 | + await page.goto('/apps/showcase_app/showcase_invoice'); |
| 39 | + await page.getByRole('button', { name: /^New$/i }).first().click(); |
| 40 | + |
| 41 | + const dialog = page.getByRole('dialog'); |
| 42 | + await expect(dialog.getByTestId('md-form-submit')).toBeVisible(); |
| 43 | + await expect(dialog.getByText('Line Items', { exact: false })).toBeVisible(); |
| 44 | + |
| 45 | + const li = dialog.getByTestId('line-items'); |
| 46 | + // The child schema loads async — wait for real headers, then assert Receipt. |
| 47 | + await li.locator('th', { hasText: 'Product' }).first().waitFor(); |
| 48 | + await expect(li.locator('th', { hasText: 'Receipt' })).toHaveCount(1); |
| 49 | + |
| 50 | + // The Receipt cell is a genuine upload control, NOT a text input (#2360 core). |
| 51 | + const fileInput = li.locator('input[type="file"]').first(); |
| 52 | + await expect(fileInput).toHaveCount(1); |
| 53 | + await expect(li.locator('input[type="text"][aria-label="Receipt"]')).toHaveCount(0); |
| 54 | + |
| 55 | + // Materialise the ghost row via a product pick (auto-fills description/price), |
| 56 | + // then give the line a quantity so it's a valid billable line. |
| 57 | + await li.getByTestId('lookup-trigger').first().click(); |
| 58 | + const option = page.getByRole('option', { name: /Widget A/i }).first(); |
| 59 | + await option.waitFor({ state: 'visible' }); |
| 60 | + await option.click(); |
| 61 | + await li.locator('input[aria-label="Qty"]').first().fill('1'); |
| 62 | + |
| 63 | + // Upload into the row's Receipt cell → a removable chip with the file name. |
| 64 | + await fileInput.setInputFiles({ name: 'receipt.png', mimeType: 'image/png', buffer: PNG }); |
| 65 | + await expect(li.getByTestId('file-cell-chip').first()).toContainText('receipt.png'); |
| 66 | + |
| 67 | + // Header fields, then submit and capture the atomic batch. |
| 68 | + const name = `INV-${Date.now()}`; |
| 69 | + await dialog.locator('input[name="name"]').fill(name); |
| 70 | + await dialog.getByTestId('lookup-trigger-account').first().click(); |
| 71 | + const acct = page.getByRole('option').first(); |
| 72 | + await acct.waitFor({ state: 'visible' }); |
| 73 | + await acct.click(); |
| 74 | + await dialog.getByTestId('select-trigger-status').first().click(); |
| 75 | + await page.getByTestId('select-option-draft').first().click(); |
| 76 | + |
| 77 | + await Promise.all([ |
| 78 | + page.waitForRequest((r) => r.url().includes('/api/v1/batch'), { timeout: 15_000 }).catch(() => null), |
| 79 | + dialog.getByTestId('md-form-submit').click(), |
| 80 | + ]); |
| 81 | + await page.waitForTimeout(500); |
| 82 | + |
| 83 | + expect(batches.length).toBeGreaterThan(0); |
| 84 | + const ops = batches[0].operations; |
| 85 | + const child = ops.find((o: any) => o.object === 'showcase_invoice_line'); |
| 86 | + expect(child).toBeTruthy(); |
| 87 | + // The uploaded file resolved to a stored object, not a blob/text placeholder. |
| 88 | + const receipt = child?.data?.receipt; |
| 89 | + expect(receipt).toBeTruthy(); |
| 90 | + expect(receipt.name || receipt.original_name).toContain('receipt'); |
| 91 | + expect(String(receipt.url)).toMatch(/^https?:\/\//); |
| 92 | +}); |
0 commit comments