|
| 1 | +import { test, expect, type Page, type Locator } from '@playwright/test'; |
| 2 | +import { selectOption } from './helpers'; |
| 3 | + |
| 4 | +/** |
| 5 | + * B3 live e2e: cascading / dependent `select` options + server-side enforcement |
| 6 | + * (#1583 / objectui#2559). |
| 7 | + * |
| 8 | + * Drives the showcase `showcase_cascade` object (framework |
| 9 | + * examples/app-showcase) — a `country` -> `province` cascade authored with |
| 10 | + * per-option `visibleWhen` + field-level `dependsOn`, plus a role-gated `tier`. |
| 11 | + * This is the live-backend counterpart to the no-backend docs e2e |
| 12 | + * `e2e/cascading-options.spec.ts`: it proves the dual-side B3 contract against a |
| 13 | + * REAL stack — |
| 14 | + * |
| 15 | + * 1. CLIENT (UX): opening the create form and driving the parent `country` |
| 16 | + * re-filters the child `province`'s OFFERED set via the canonical |
| 17 | + * @objectstack/formula engine, and clears a now-invalid child value when |
| 18 | + * the parent changes (cascade-clear). |
| 19 | + * 2. SERVER (boundary): the objectql rule-validator re-evaluates the SUBMITTED |
| 20 | + * option's `visibleWhen` and REJECTS an out-of-set value (client hiding is |
| 21 | + * UX, not a security boundary) — verified by POSTing a bad value straight |
| 22 | + * at the data API and asserting a `VALIDATION_FAILED` / `invalid_option` |
| 23 | + * violation, and that the matching in-set value is accepted. |
| 24 | + * |
| 25 | + * Like every other spec under e2e/live/, this drives the real console (:5180) + |
| 26 | + * backend (:3000). The default/CI Playwright run ignores the e2e/live directory, |
| 27 | + * so this is opt-in via `pnpm test:e2e:live` with the stack up (auth is handled |
| 28 | + * by e2e/live/global-setup.ts). |
| 29 | + */ |
| 30 | + |
| 31 | +const API = process.env.LIVE_API_URL || 'http://localhost:3000'; |
| 32 | +const OBJECT = 'showcase_cascade'; |
| 33 | + |
| 34 | +/** |
| 35 | + * Open a field's Radix Select in the create dialog and return the OFFERED option |
| 36 | + * values (the `select-option-<value>` testid suffixes), sorted, then close it. |
| 37 | + * Only the open select's options are mounted in the portal, so a bare |
| 38 | + * `select-option-*` query is unambiguous. |
| 39 | + */ |
| 40 | +async function offeredValues(page: Page, dialog: Locator, fieldName: string): Promise<string[]> { |
| 41 | + await dialog.getByTestId(`select-trigger-${fieldName}`).first().click(); |
| 42 | + const options = page.locator('[data-testid^="select-option-"]'); |
| 43 | + await options.first().waitFor({ state: 'visible' }); |
| 44 | + const testids = await options.evaluateAll((els) => |
| 45 | + els.map((el) => (el as HTMLElement).dataset.testid ?? ''), |
| 46 | + ); |
| 47 | + await page.keyboard.press('Escape'); |
| 48 | + return testids.map((t) => t.replace('select-option-', '')).filter(Boolean).sort(); |
| 49 | +} |
| 50 | + |
| 51 | +test('province options re-filter live as country changes, and the stale value clears', async ({ page }) => { |
| 52 | + await page.goto(`/apps/showcase_app/${OBJECT}`); |
| 53 | + await page.getByRole('button', { name: /^(New|新建)$/i }).first().click(); |
| 54 | + |
| 55 | + const dialog = page.getByRole('dialog'); |
| 56 | + await expect(dialog.getByTestId('md-form-submit')).toBeVisible(); |
| 57 | + await dialog.locator('input[name="name"]').fill(`Cascade ${Date.now()}`); |
| 58 | + |
| 59 | + // --- country = China -> the dependent province offers only Chinese provinces. --- |
| 60 | + await selectOption(dialog, 'country', 'cn'); |
| 61 | + await expect(dialog.getByTestId('select-trigger-province')).toBeVisible(); |
| 62 | + expect(await offeredValues(page, dialog, 'province')).toEqual(['gd', 'zj']); |
| 63 | + |
| 64 | + // Choose one so we can prove the cascade-clear on the next parent change. |
| 65 | + await selectOption(dialog, 'province', 'zj'); |
| 66 | + await expect(dialog.getByTestId('select-trigger-province')).toContainText(/zhejiang/i); |
| 67 | + |
| 68 | + // --- country = United States -> the offered set flips and the stale value clears. --- |
| 69 | + await selectOption(dialog, 'country', 'us'); |
| 70 | + expect(await offeredValues(page, dialog, 'province')).toEqual(['ca', 'tx']); |
| 71 | + // 'Zhejiang' is no longer offered under country=us, so the widget dropped it. |
| 72 | + await expect(dialog.getByTestId('select-trigger-province')).not.toContainText(/zhejiang/i); |
| 73 | +}); |
| 74 | + |
| 75 | +test('the server rejects an out-of-set option value, and accepts an in-set one', async ({ request }) => { |
| 76 | + // Out-of-set: province 'zj' is visible only when country=='cn'. Submitting it |
| 77 | + // under country=='us' must be rejected by the objectql rule-validator — |
| 78 | + // client hiding is UX, the server is the boundary. |
| 79 | + const bad = await request.post(`${API}/api/v1/data/${OBJECT}`, { |
| 80 | + data: { name: `Bad ${Date.now()}`, country: 'us', province: 'zj' }, |
| 81 | + }); |
| 82 | + expect(bad.status(), await bad.text()).toBe(400); |
| 83 | + const badBody = await bad.json(); |
| 84 | + expect(badBody.code).toBe('VALIDATION_FAILED'); |
| 85 | + const fields: Array<{ field?: string; code?: string }> = badBody.fields ?? []; |
| 86 | + expect( |
| 87 | + fields.some((f) => f.field === 'province' && f.code === 'invalid_option'), |
| 88 | + `expected an invalid_option violation on province, got ${JSON.stringify(fields)}`, |
| 89 | + ).toBeTruthy(); |
| 90 | + |
| 91 | + // In-set: the same value is accepted when the cascade predicate holds. |
| 92 | + const ok = await request.post(`${API}/api/v1/data/${OBJECT}`, { |
| 93 | + data: { name: `Good ${Date.now()}`, country: 'cn', province: 'zj' }, |
| 94 | + }); |
| 95 | + expect(ok.status(), await ok.text()).toBe(201); |
| 96 | + const created = await ok.json(); |
| 97 | + const id = created?.id ?? created?.record?.id ?? created?.data?.id; |
| 98 | + expect(id, 'created record id').toBeTruthy(); |
| 99 | + |
| 100 | + // Clean up the accepted row so reruns stay idempotent. |
| 101 | + if (id) await request.delete(`${API}/api/v1/data/${OBJECT}/${id}`); |
| 102 | +}); |
0 commit comments