Skip to content

Commit 3eff499

Browse files
os-zhuangclaude
andauthored
test(e2e-live): B3 cascading-options live e2e — client re-filter + server rejection (#2559 / #1583) (#2562)
Adds e2e/live/cascading-options.spec.ts, the live-backend counterpart to the no-backend docs e2e (e2e/cascading-options.spec.ts). Against the real console + backend it drives the showcase `showcase_cascade` create form and asserts the dual-side B3 contract: - CLIENT: the child `province`'s offered option set re-filters as the parent `country` changes, and a now-invalid child value clears (cascade-clear). - SERVER: POST /api/v1/data/showcase_cascade rejects an out-of-set option value (400 VALIDATION_FAILED / invalid_option) and accepts the in-set one — client hiding is UX, the server is the boundary. Excluded from the default/CI run (testIgnore '**/live/**'); opt-in via `pnpm test:e2e:live` with the stack up. Requires the framework showcase_cascade fixture (framework #2559). Claude-Session: https://claude.ai/code/session_01S91NyYJURiQTKmF9q3AXxg Co-authored-by: Claude <noreply@anthropic.com>
1 parent f94905d commit 3eff499

1 file changed

Lines changed: 102 additions & 0 deletions

File tree

e2e/live/cascading-options.spec.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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

Comments
 (0)