Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion e2e/live/cascading-options.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ test('province options re-filter live as country changes, and the stale value cl
await page.getByRole('button', { name: /^(New|新建)$/i }).first().click();

const dialog = page.getByRole('dialog');
await expect(dialog.getByTestId('md-form-submit')).toBeVisible();
// `showcase_cascade` has no child collection, so New opens the flat
// ModalForm: its own footer, not MasterDetailForm's `md-form-submit`.
await expect(dialog.getByTestId('modal-form-footer')).toBeVisible();
await dialog.locator('input[name="name"]').fill(`Cascade ${Date.now()}`);

// --- country = China -> the dependent province offers only Chinese provinces. ---
Expand Down
40 changes: 21 additions & 19 deletions e2e/live/detail-related-list.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,34 +12,36 @@ import { test, expect } from '@playwright/test';
* list is present.
*/
test('Account detail page auto-renders a relationship-derived Projects related list', async ({ page }) => {
// 1) Open the Accounts list and drill into the first record.
// 1) Open the Accounts list and drill into Northwind, a seeded account that
// owns projects (the status assertion below needs rows to read).
await page.goto('/apps/showcase_app/showcase_account');

// The data grid renders the primary (name) cell as a link. Clicking it opens
// the record (the list uses a split-pane drawer keyed by `?recordId=`). Grab
// the id and navigate to the canonical full-page detail route.
const firstRowNameLink = page.getByRole('row').nth(1).getByRole('link').first();
await firstRowNameLink.waitFor({ state: 'visible', timeout: 15_000 });
await firstRowNameLink.click();
// The data grid renders the primary (name) cell as a link to the record's
// full-page detail route, `/record/<id>`. Grab the id from that route.
const nameLink = page.getByRole('row').getByRole('link', { name: 'Northwind', exact: true }).first();
await nameLink.waitFor({ state: 'visible', timeout: 15_000 });
await nameLink.click();

await page.waitForURL(/[?&]recordId=/, { timeout: 15_000 });
const recordId = new URL(page.url()).searchParams.get('recordId');
await page.waitForURL(/\/showcase_account\/record\/[^/?#]+/, { timeout: 15_000 });
const recordId = new URL(page.url()).pathname.split('/record/')[1]?.split('/')[0];
expect(recordId).toBeTruthy();

// 2) Open the canonical full-page detail (synthesized record page).
await page.goto(`/apps/showcase_app/showcase_account/record/${recordId}`);
await expect(page).toHaveURL(/\/showcase_account\/record\//, { timeout: 15_000 });

// 3) The synthesized detail page groups related lists under a "Related" tab.
// This tab only exists because `deriveRelatedLists` found the
// showcase_project.account relationship — it's the read-side mirror at work.
const relatedTab = page.getByRole('tab', { name: /^Related$/i });
await relatedTab.waitFor({ state: 'visible', timeout: 15_000 });
await relatedTab.click();
// 3) `showcase_project.account` declares `relatedList: 'primary'`, so the
// synthesized page gives that list its own tab, titled by its declared
// `relatedListTitle` ("Projects"); non-primary lists share a "Related"
// tab. Either way the tab only exists because `deriveRelatedLists` found
// the relationship — it's the read-side mirror at work.
// (The tab's name gains a row-count badge once the count loads.)
const projectsTab = page.getByRole('tab', { name: /^Projects/ });
await projectsTab.waitFor({ state: 'visible', timeout: 15_000 });
await projectsTab.click();

// 4) The relationship-derived related list renders under its declared
// `relatedListTitle` ("Projects").
const relatedPanel = page.getByRole('tabpanel', { name: /Related/i });
// 4) The relationship-derived related list renders in that tab.
const relatedPanel = page.getByRole('tabpanel', { name: /^Projects/ });
await expect(relatedPanel.getByText('Projects', { exact: false }).first()).toBeVisible({ timeout: 15_000 });
// The status column renders as a friendly, capitalized badge label (e.g.
// "Planned"/"Active") — NOT the raw lowercase value ("planned"). This guards
Expand All @@ -48,5 +50,5 @@ test('Account detail page auto-renders a relationship-derived Projects related l
await expect(
relatedPanel.getByText(/^(Planned|Active|On Hold|Completed|Cancelled)$/).first(),
).toBeVisible({ timeout: 15_000 });
await expect(relatedPanel.getByText('planned', { exact: true })).toHaveCount(0);
await expect(relatedPanel.getByText(/^(planned|active|on_hold|completed|cancelled)$/)).toHaveCount(0);
});
10 changes: 9 additions & 1 deletion e2e/live/field-conditional-rules.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,24 @@ test('header fields react to Status via CEL visibleWhen / readonlyWhen / require

const paidOn = dialog.locator('[name="paid_on"]');
const taxRate = dialog.locator('[name="tax_rate"]');
// A read-only field renders its value as text inside the form's read-only
// field group, in place of the editor — so "locked" is: the input is gone
// and that group is shown.
const taxRateReadonly = dialog.locator('[data-field="tax_rate"] [data-slot="readonly-field-group"]');

// --- Initial (status unset / draft): paid_on hidden, tax_rate editable,
// issued_on not yet required. ---
await expect(paidOn).toHaveCount(0);
await expect(taxRate).toBeEnabled();
await expect(taxRateReadonly).toHaveCount(0);
expect(await isRequired(dialog, 'Issued On')).toBe(false);

// --- Paid: tax_rate locks (readonlyWhen), paid_on appears + required,
// issued_on required. ---
await selectOption(dialog, 'status', 'paid');
await expect(paidOn).toHaveCount(1);
await expect(taxRate).toBeDisabled();
await expect(taxRate).toHaveCount(0);
await expect(taxRateReadonly).toBeVisible();
expect(await isRequired(dialog, 'Paid On')).toBe(true);
expect(await isRequired(dialog, 'Issued On')).toBe(true);

Expand All @@ -61,12 +67,14 @@ test('header fields react to Status via CEL visibleWhen / readonlyWhen / require
await selectOption(dialog, 'status', 'sent');
await expect(paidOn).toHaveCount(0);
await expect(taxRate).toBeEnabled();
await expect(taxRateReadonly).toHaveCount(0);
expect(await isRequired(dialog, 'Issued On')).toBe(true);

// --- Draft: everything relaxes — tax_rate editable, paid_on hidden,
// issued_on optional. ---
await selectOption(dialog, 'status', 'draft');
await expect(paidOn).toHaveCount(0);
await expect(taxRate).toBeEnabled();
await expect(taxRateReadonly).toHaveCount(0);
expect(await isRequired(dialog, 'Issued On')).toBe(false);
});
4 changes: 3 additions & 1 deletion e2e/live/form-view-subforms.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* and persisted in the batch (so the parent total rolls it up server-side).
*/
test('New <object> modal renders relationship-derived subforms and submits an atomic batch', async ({ page }) => {
const batches: any[] = [];

Check warning on line 20 in e2e/live/form-view-subforms.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
page.on('request', (r) => {
if (r.method() === 'POST' && r.url().includes('/api/v1/batch')) {
try { batches.push(r.postDataJSON()); } catch { /* ignore */ }
Expand All @@ -29,7 +29,9 @@

const dialog = page.getByRole('dialog');
await expect(dialog.getByTestId('md-form-submit')).toBeVisible();
await expect(dialog.getByText('Line Items', { exact: false })).toBeVisible();
// The section's heading, by role: a bare text match also hits the dialog's
// sr-only description ("…its line items, then save.") and fails strict mode.
await expect(dialog.getByRole('heading', { name: 'Line Items' })).toBeVisible();

const name = `INV-${Date.now()}`;
await dialog.locator('input[name="name"]').fill(name);
Expand Down Expand Up @@ -70,7 +72,7 @@
expect(ops[0]).toMatchObject({ object: 'showcase_invoice', action: 'create' });
expect(ops[0].data.name).toBe(name);
expect(ops[0].data.status).toBe('draft');
const child = ops.find((o: any) => o.object === 'showcase_invoice_line');

Check warning on line 75 in e2e/live/form-view-subforms.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
expect(child?.data?.invoice).toEqual({ $ref: 0 });
expect(child?.data?.product).toBeTruthy(); // the chosen product id
expect(child?.data?.description).toBe('Standard widget'); // auto-filled
Expand All @@ -78,5 +80,5 @@
expect(Number(child?.data?.amount)).toBe(59.98); // computed (2 × 29.99)
expect(Number(child?.data?.position)).toBe(0); // sort position stamped from row order
// The empty ghost line must NOT have been persisted as a blank child.
expect(ops.filter((o: any) => o.object === 'showcase_invoice_line')).toHaveLength(1);

Check warning on line 83 in e2e/live/form-view-subforms.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
});
26 changes: 19 additions & 7 deletions e2e/live/grid-file-upload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
* removable chip), never a text `<Input>`;
* • picking a file uploads it through the console's UploadProvider adapter and
* shows a chip with the file name;
* • submit carries the resolved file object ({ name, url, … }) on the line.
* • submit carries the uploaded file's id on the line — ADR-0104's stored
* form for a file field is an opaque file id, never an inline
* { name, url } blob — and that id resolves through the file door
* (`GET /api/v1/storage/files/:fileId`) to the bytes that were uploaded.
*
* Live-only (needs the storage service + a real backend); runs under
* `pnpm test:e2e:live`, not the mocked PR e2e job.
Expand All @@ -27,8 +30,10 @@
'base64',
);

test('inline line-item grid uploads a per-row file and persists it in the batch', async ({ page }) => {
const API = process.env.LIVE_API_URL || 'http://localhost:3000';

test('inline line-item grid uploads a per-row file and persists it in the batch', async ({ page, request }) => {
const batches: any[] = [];

Check warning on line 36 in e2e/live/grid-file-upload.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
page.on('request', (r) => {
if (r.method() === 'POST' && r.url().includes('/api/v1/batch')) {
try { batches.push(r.postDataJSON()); } catch { /* ignore */ }
Expand All @@ -40,7 +45,9 @@

const dialog = page.getByRole('dialog');
await expect(dialog.getByTestId('md-form-submit')).toBeVisible();
await expect(dialog.getByText('Line Items', { exact: false })).toBeVisible();
// The section's heading, by role: a bare text match also hits the dialog's
// sr-only description ("…its line items, then save.") and fails strict mode.
await expect(dialog.getByRole('heading', { name: 'Line Items' })).toBeVisible();

const li = dialog.getByTestId('line-items');
// The child schema loads async — wait for real headers, then assert Receipt.
Expand Down Expand Up @@ -82,11 +89,16 @@

expect(batches.length).toBeGreaterThan(0);
const ops = batches[0].operations;
const child = ops.find((o: any) => o.object === 'showcase_invoice_line');

Check warning on line 92 in e2e/live/grid-file-upload.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
expect(child).toBeTruthy();
// The uploaded file resolved to a stored object, not a blob/text placeholder.
// The line carries the uploaded file's id (ADR-0104 D3, "Stored form": an
// opaque file id), not an inline blob, a url, or the file name as text.
const receipt = child?.data?.receipt;
expect(receipt).toBeTruthy();
expect(receipt.name || receipt.original_name).toContain('receipt');
expect(String(receipt.url)).toMatch(/^https?:\/\//);
expect(typeof receipt, `the receipt is not a file id: ${JSON.stringify(receipt)}`).toBe('string');
expect(receipt).not.toContain('receipt.png');
// The id resolves through the file door to exactly the bytes uploaded above.
const file = await request.get(`${API}/api/v1/storage/files/${encodeURIComponent(receipt)}`);
expect(file.status()).toBe(200);
expect(file.headers()['content-type']).toContain('image/png');
expect(Buffer.compare(await file.body(), PNG)).toBe(0);
});
Loading
Loading