|
| 1 | +// Copyright The Linux Foundation and each contributor to LFX. |
| 2 | +// SPDX-License-Identifier: MIT |
| 3 | + |
| 4 | +/** |
| 5 | + * Create Quick-Link E2E — smoke set. |
| 6 | + * |
| 7 | + * Exercises the rail "Create" button → type popover → project-selection dialog. |
| 8 | + * Visibility is driven by CreatePermissionService, which derives create |
| 9 | + * capability from the project `writer` grant returned by GET /api/projects. The |
| 10 | + * eligible projects are therefore the authenticated test user's real grants, so |
| 11 | + * these tests assert structure/behavior rather than specific project names, and |
| 12 | + * skip entirely when the user has no create permission (the button is hidden). |
| 13 | + * |
| 14 | + * Coverage map: |
| 15 | + * - S1: rail "Create" button renders for a create-capable user |
| 16 | + * - S2: clicking it opens a popover listing the six artifact types, in grouped order, with descriptions |
| 17 | + * - S3: picking a type opens the dialog (header + project selector) with Continue disabled, |
| 18 | + * and choosing an eligible project via the selector enables Continue |
| 19 | + * - S4: the dialog's project selector reuses the sidebar pattern (search + All/Foundations/Projects |
| 20 | + * tabs) and renders a selectable list. Writer-scoping of that list is guaranteed by the dialog |
| 21 | + * feeding the curated `creatableProjects` (verified in production-code review), not asserted here. |
| 22 | + * - S5: a single eligible project is auto-selected on open (Continue enabled without a pick); with |
| 23 | + * multiple eligible projects nothing is pre-selected and Continue stays gated until the user picks |
| 24 | + * - S6: Continue routes into the create flow — lands on the lens-prefixed create URL carrying ?project=<slug> |
| 25 | + * |
| 26 | + * Prerequisites: |
| 27 | + * - Dev server reachable at the Playwright baseURL |
| 28 | + * - `apps/lfx-one/.env` populated with TEST_USERNAME / TEST_PASSWORD (see global-setup.ts) |
| 29 | + * - The test user must hold `writer` on at least one project for S1–S3 to run; |
| 30 | + * otherwise the suite skips (no create permission → no button, by design). |
| 31 | + * |
| 32 | + * Note: this suite stops at the dialog boundary. It does not assert the post-Continue |
| 33 | + * create page — that path is enforced by each route's writerGuard. |
| 34 | + */ |
| 35 | + |
| 36 | +import { expect, Locator, Page, test } from '@playwright/test'; |
| 37 | + |
| 38 | +const APP_HOME = '/'; |
| 39 | +const RAIL_TIMEOUT = 30_000; |
| 40 | + |
| 41 | +test.setTimeout(120_000); |
| 42 | + |
| 43 | +// Hard skip when the auth-bootstrap failed — mirror org-selector.spec.ts so CI triage |
| 44 | +// isn't sent chasing a regression that's really a credentials issue. |
| 45 | +function skipWhenAuthMissing(page: Page): void { |
| 46 | + try { |
| 47 | + const { hostname } = new URL(page.url()); |
| 48 | + if (hostname === 'auth0.com' || hostname.endsWith('.auth0.com')) { |
| 49 | + test.skip(true, 'TEST_USERNAME / TEST_PASSWORD not configured — see global-setup.ts'); |
| 50 | + } |
| 51 | + } catch { |
| 52 | + // Malformed URL — keep running; a failure here is useful signal, not noise. |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +// Skip when the test user has no create permission — the button is intentionally absent. |
| 57 | +async function skipWhenNoCreatePermission(page: Page): Promise<void> { |
| 58 | + const trigger = page.getByTestId('create-rail-button'); |
| 59 | + const visible = await trigger.isVisible().catch(() => false); |
| 60 | + if (!visible) { |
| 61 | + test.skip(true, 'Test user holds `writer` on no project — button hidden by design.'); |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +async function openCreateMenu(page: Page): Promise<void> { |
| 66 | + const trigger = page.getByTestId('create-rail-button'); |
| 67 | + await expect(trigger).toBeVisible({ timeout: RAIL_TIMEOUT }); |
| 68 | + await trigger.click(); |
| 69 | + await expect(page.getByTestId('create-menu')).toBeVisible({ timeout: 5_000 }); |
| 70 | +} |
| 71 | + |
| 72 | +async function openDialogForType(page: Page, type: 'meeting' | 'newsletter' | 'vote' | 'survey' | 'group' | 'mailing-list'): Promise<void> { |
| 73 | + await openCreateMenu(page); |
| 74 | + await page.getByTestId(`create-menu-option-${type}`).click(); |
| 75 | + await expect(page.getByTestId('create-artifact-dialog')).toBeVisible({ timeout: 5_000 }); |
| 76 | +} |
| 77 | + |
| 78 | +function continueButton(page: Page): Locator { |
| 79 | + return page.getByTestId('create-artifact-continue-button').locator('button'); |
| 80 | +} |
| 81 | + |
| 82 | +test.describe('Create Quick-Link — rail popover + dialog smoke set', () => { |
| 83 | + test.beforeEach(async ({ page }) => { |
| 84 | + await page.goto(APP_HOME, { waitUntil: 'domcontentloaded' }); |
| 85 | + skipWhenAuthMissing(page); |
| 86 | + // Give writer-driven visibility a moment to resolve before gating. |
| 87 | + await page |
| 88 | + .getByTestId('create-rail-button') |
| 89 | + .waitFor({ state: 'visible', timeout: RAIL_TIMEOUT }) |
| 90 | + .catch(() => undefined); |
| 91 | + await skipWhenNoCreatePermission(page); |
| 92 | + }); |
| 93 | + |
| 94 | + // S1 — rail button renders for a create-capable user |
| 95 | + test('S1: the rail "Create" button is visible for a create-capable user', async ({ page }) => { |
| 96 | + await expect(page.getByTestId('create-rail-button')).toBeVisible({ timeout: RAIL_TIMEOUT }); |
| 97 | + }); |
| 98 | + |
| 99 | + // S2 — the button opens a popover listing all six types, in the grouped sequence |
| 100 | + test('S2: clicking the button opens a popover with the six artifact types in grouped order', async ({ page }) => { |
| 101 | + await openCreateMenu(page); |
| 102 | + |
| 103 | + // Grouped sequence: Engage (meeting, newsletter) | Decide (vote, survey) | Organize (group, mailing-list). |
| 104 | + const expectedOrder = ['meeting', 'newsletter', 'vote', 'survey', 'group', 'mailing-list']; |
| 105 | + |
| 106 | + for (const type of expectedOrder) { |
| 107 | + await expect(page.getByTestId(`create-menu-option-${type}`)).toBeVisible(); |
| 108 | + } |
| 109 | + |
| 110 | + // Assert render order matches the constant order, not just presence. |
| 111 | + const renderedOrder = await page |
| 112 | + .getByTestId('create-menu') |
| 113 | + .locator('[data-testid^="create-menu-option-"]') |
| 114 | + .evaluateAll((nodes) => nodes.map((n) => n.getAttribute('data-testid')?.replace('create-menu-option-', ''))); |
| 115 | + expect(renderedOrder).toEqual(expectedOrder); |
| 116 | + |
| 117 | + await expect(page.getByTestId('create-menu-option-meeting')).toContainText('Schedule a recurring or one-time meeting'); |
| 118 | + }); |
| 119 | + |
| 120 | + // S3 — picking a project via the selector enables Continue. (On-open enabled/disabled state is S5's |
| 121 | + // job; asserting "disabled on open" here would be wrong for a single-eligible-project account, where |
| 122 | + // the dialog auto-selects and Continue is already enabled.) |
| 123 | + test('S3: picking "Meeting" opens the dialog and choosing a project enables Continue', async ({ page }) => { |
| 124 | + await openDialogForType(page, 'meeting'); |
| 125 | + |
| 126 | + // Open the reused project-selector (same UI as the sidebar). Scope the trigger to the dialog — |
| 127 | + // the same `project-selector` testid is emitted by the sidebar's instance in project/foundation lens. |
| 128 | + const dialog = page.getByTestId('create-artifact-dialog'); |
| 129 | + await dialog.getByTestId('project-selector').click(); |
| 130 | + const panel = page.getByTestId('project-selector-panel'); |
| 131 | + await expect(panel).toBeVisible({ timeout: 5_000 }); |
| 132 | + const firstItem = panel.locator('[data-testid^="lens-item-"]').first(); |
| 133 | + await expect(firstItem).toBeVisible({ timeout: 5_000 }); |
| 134 | + await firstItem.click(); |
| 135 | + |
| 136 | + await expect(continueButton(page)).toBeEnabled(); |
| 137 | + }); |
| 138 | + |
| 139 | + // S4 — the project selector reuses the sidebar pattern (search + tabs) and renders the writer-scoped list |
| 140 | + test('S4: the project selector reuses the search + tabs pattern and renders selectable projects', async ({ page }) => { |
| 141 | + await openDialogForType(page, 'meeting'); |
| 142 | + |
| 143 | + // Scope the trigger to the dialog — the sidebar renders the same `project-selector` testid in project/foundation lens. |
| 144 | + const dialog = page.getByTestId('create-artifact-dialog'); |
| 145 | + await dialog.getByTestId('project-selector').click(); |
| 146 | + const panel = page.getByTestId('project-selector-panel'); |
| 147 | + await expect(panel).toBeVisible({ timeout: 5_000 }); |
| 148 | + |
| 149 | + // Familiar sidebar pattern: search input + All/Foundations/Projects tabs. |
| 150 | + await expect(panel.getByTestId('project-search-input')).toBeVisible(); |
| 151 | + await expect(panel.getByRole('button', { name: 'All', exact: true })).toBeVisible(); |
| 152 | + await expect(panel.getByRole('button', { name: 'Foundations', exact: true })).toBeVisible(); |
| 153 | + await expect(panel.getByRole('button', { name: 'Projects', exact: true })).toBeVisible(); |
| 154 | + |
| 155 | + // The list is the dialog's writer-scoped `creatableProjects` (fed via the selector's curated `items` |
| 156 | + // input), never the view-scoped nav catalog. Assert on the selector's contract — a non-empty set of |
| 157 | + // selectable lens items — rather than hardcoding prod catalog names: this suite is real-API and |
| 158 | + // name-agnostic (see file docstring + testing-best-practices "assert on shape, not fixtures"). |
| 159 | + await expect(panel.locator('[data-testid^="lens-item-"]').first()).toBeVisible({ timeout: 5_000 }); |
| 160 | + }); |
| 161 | + |
| 162 | + // S5 — auto-select single: a lone eligible project is pre-selected so Continue is enabled without a pick |
| 163 | + test('S5: a single eligible project is auto-selected; multiple require an explicit pick', async ({ page }) => { |
| 164 | + await openDialogForType(page, 'meeting'); |
| 165 | + const dialog = page.getByTestId('create-artifact-dialog'); |
| 166 | + const trigger = dialog.getByTestId('project-selector'); |
| 167 | + |
| 168 | + // Count eligible options, then toggle the panel closed via the trigger (Escape could close the dialog). |
| 169 | + await trigger.click(); |
| 170 | + const panel = page.getByTestId('project-selector-panel'); |
| 171 | + await expect(panel).toBeVisible({ timeout: 5_000 }); |
| 172 | + const itemCount = await panel.locator('[data-testid^="lens-item-"]').count(); |
| 173 | + await trigger.click(); |
| 174 | + await expect(panel).toBeHidden(); |
| 175 | + |
| 176 | + if (itemCount === 1) { |
| 177 | + // Auto-selected on open — no manual pick needed. |
| 178 | + await expect(continueButton(page)).toBeEnabled(); |
| 179 | + } else { |
| 180 | + // Multiple options: nothing pre-selected, Continue gated until the user picks. |
| 181 | + await expect(continueButton(page)).toBeDisabled(); |
| 182 | + } |
| 183 | + }); |
| 184 | + |
| 185 | + // S6 — Continue exercises the create-navigation path: lands on the lens-prefixed create URL carrying ?project=<slug> |
| 186 | + test('S6: Continue navigates to the create page carrying the selected project slug', async ({ page }) => { |
| 187 | + await openDialogForType(page, 'meeting'); |
| 188 | + const dialog = page.getByTestId('create-artifact-dialog'); |
| 189 | + await dialog.getByTestId('project-selector').click(); |
| 190 | + const panel = page.getByTestId('project-selector-panel'); |
| 191 | + await expect(panel).toBeVisible({ timeout: 5_000 }); |
| 192 | + |
| 193 | + // Capture the picked project's slug from its data-testid (`lens-item-<slug>`). |
| 194 | + const firstItem = panel.locator('[data-testid^="lens-item-"]').first(); |
| 195 | + await expect(firstItem).toBeVisible({ timeout: 5_000 }); |
| 196 | + const slug = (await firstItem.getAttribute('data-testid'))?.replace('lens-item-', '') ?? ''; |
| 197 | + expect(slug).not.toBe(''); |
| 198 | + await firstItem.click(); |
| 199 | + |
| 200 | + await continueButton(page).click(); |
| 201 | + |
| 202 | + // onContinue aligns the lens then navigates; lensRedirectGuard forwards to the lens-prefixed mount, |
| 203 | + // preserving ?project=. Require the lens prefix explicitly (foundation|project) — a bare |
| 204 | + // /meetings/create would mean setLens/lensRedirectGuard didn't run, so it must NOT match. |
| 205 | + await expect(page).toHaveURL(new RegExp(`/(foundation|project)/meetings/create\\?.*project=${slug}`), { timeout: 15_000 }); |
| 206 | + }); |
| 207 | +}); |
0 commit comments