From edcfd0c6f298ca677d3bbf54b53e920dfb34350a Mon Sep 17 00:00:00 2001 From: Maximilian Kindshofer Date: Mon, 14 Sep 2026 20:00:06 +0200 Subject: [PATCH 01/21] test(widget): file the install-status success path under its own contract line, P7 The six tests that pin what the status reports for a connected, enabled or outdated install were written under P3, which only promises that a database failure is reported as such. P7 now names the guarantee they hold - the status reports what the site sent - and the header says when it was added and that it was confirmed. Co-Authored-By: Claude Fable 5.1 --- .../__tests__/widget-install-pairing.test.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/web/src/lib/server/domains/settings/__tests__/widget-install-pairing.test.ts b/apps/web/src/lib/server/domains/settings/__tests__/widget-install-pairing.test.ts index 40389aa384..61fe04e513 100644 --- a/apps/web/src/lib/server/domains/settings/__tests__/widget-install-pairing.test.ts +++ b/apps/web/src/lib/server/domains/settings/__tests__/widget-install-pairing.test.ts @@ -4,10 +4,11 @@ * and never the signing secret; the code is minted on copy by an admin with * settings.manage, and a mint failure toasts and copies nothing. * - P3 Reading the install status reports a database failure as such. - * - * The status tests below read P3 as the guarantee that what the status reports - * is what the instance actually knows — a failure is reported as a failure, and - * an install nobody has seen is not reported as an install. + * - P7 The install status reports what the site sent: connected only once a + * ping was seen, enabled only when the stored config says so, the SDK + * versions as stored, and an update request only for a connected site whose + * SDK is behind. (Added 2026-09-14 after the mutation run showed the success + * path unpinned; confirmed by the user.) */ import { beforeEach, describe, expect, it, vi } from 'vitest' import fc from 'fast-check' @@ -200,7 +201,7 @@ describe('getWidgetInstallStatus reports what the instance knows', () => { const firstSeen = new Date('2026-09-01T10:00:00.000Z') const lastSeen = new Date('2026-09-12T08:30:00.000Z') - it('reports a site that has pinged as connected, with the evidence it sent (P3)', async () => { + it('reports a site that has pinged as connected, with the evidence it sent (P7)', async () => { requireSettingsCached.mockResolvedValue( settingsWithInstallEvidence({ widgetConfig: JSON.stringify({ enabled: true }), @@ -222,7 +223,7 @@ describe('getWidgetInstallStatus reports what the instance knows', () => { }) }) - it('reports a site that has never pinged as not connected (P3)', async () => { + it('reports a site that has never pinged as not connected (P7)', async () => { requireSettingsCached.mockResolvedValue( settingsWithInstallEvidence({ widgetConfig: JSON.stringify({ enabled: true }) }) ) @@ -238,7 +239,7 @@ describe('getWidgetInstallStatus reports what the instance knows', () => { }) }) - it('reports the widget as off when the stored config says so (P3)', async () => { + it('reports the widget as off when the stored config says so (P7)', async () => { requireSettingsCached.mockResolvedValue( settingsWithInstallEvidence({ widgetConfig: JSON.stringify({ enabled: false }) }) ) @@ -246,7 +247,7 @@ describe('getWidgetInstallStatus reports what the instance knows', () => { await expect(getWidgetInstallStatus()).resolves.toMatchObject({ enabled: false }) }) - it('does not read a merely truthy stored flag as on (P3)', async () => { + it('does not read a merely truthy stored flag as on (P7)', async () => { requireSettingsCached.mockResolvedValue( settingsWithInstallEvidence({ widgetConfig: '{"enabled":"yes"}' }) ) @@ -254,7 +255,7 @@ describe('getWidgetInstallStatus reports what the instance knows', () => { await expect(getWidgetInstallStatus()).resolves.toMatchObject({ enabled: false }) }) - it('asks a connected site running an older SDK to update (P3)', async () => { + it('asks a connected site running an older SDK to update (P7)', async () => { requireSettingsCached.mockResolvedValue( settingsWithInstallEvidence({ widgetConfig: JSON.stringify({ enabled: true }), @@ -272,7 +273,7 @@ describe('getWidgetInstallStatus reports what the instance knows', () => { }) }) - it('does not ask a site nobody has seen to update, however old its SDK (P3)', async () => { + it('does not ask a site nobody has seen to update, however old its SDK (P7)', async () => { requireSettingsCached.mockResolvedValue( settingsWithInstallEvidence({ widgetConfig: JSON.stringify({ enabled: true }), @@ -286,7 +287,7 @@ describe('getWidgetInstallStatus reports what the instance knows', () => { }) }) - it('never reports an update for a site it has no install evidence for (P3)', async () => { + it('never reports an update for a site it has no install evidence for (P7)', async () => { const seenAt = fc.option( fc.integer({ min: 0, max: 2_000_000_000_000 }).map((ms) => new Date(ms)), { nil: null } From edc681b8ca6ec1dc4cc7c760d8acf3926afb3c40 Mon Sep 17 00:00:00 2001 From: James Morton Date: Fri, 11 Sep 2026 15:38:12 +0100 Subject: [PATCH 02/21] feat(ui): migrate overlay primitives from Radix to Base UI Replace @radix-ui/* with @base-ui/react across the shared primitives (dialog, popover, menu, select, tooltip, tabs, switch, checkbox, collapsible, sheet, slider, avatar, badge, button, form, label, scroll-area), with an asChild composition helper, per-component usability e2e, and a hydration wait helper. Also fixes crossed-fingers emoji resolution/shortcuts, emoji recents, and two migration regressions found in browser testing (dead reply composer, sticky radio menus). Verified: 15k unit green (1 pre-existing vendor-parity fail), typecheck/lint clean, base-ui/boards/inbox/emoji e2e green. (cherry picked from commit 0935071a6373f375300bb3b260d19e593171891b) --- .../tests/admin/base-ui-components.spec.ts | 202 ++++++++++ apps/web/e2e/tests/admin/boards.spec.ts | 11 +- .../admin/emoji-shortcuts-verify.spec.ts | 110 ++++++ .../e2e/tests/admin/post-management.spec.ts | 46 ++- .../web/e2e/tests/admin/settings-tags.spec.ts | 2 +- .../admin/settings-user-attributes.spec.ts | 4 +- apps/web/e2e/tests/admin/statuses.spec.ts | 10 +- .../e2e/tests/admin/users-segments.spec.ts | 6 +- .../tests/public/base-ui-components.spec.ts | 54 +++ apps/web/e2e/tests/public/post-list.spec.ts | 2 +- apps/web/e2e/tests/public/roadmap.spec.ts | 2 +- apps/web/e2e/utils/helpers.ts | 29 +- apps/web/package.json | 18 +- .../admin/admin-author-hover-card.tsx | 2 +- .../automation/assistant-basics-card.tsx | 4 +- .../automation/connectors/policy-dial.tsx | 2 +- .../__tests__/branch-editor.test.tsx | 8 +- .../inspector/__tests__/csat-editor.test.tsx | 10 +- .../inspector/csat-editor.tsx | 2 +- .../inspector/variable-insert-menu.tsx | 2 +- .../admin/automation/workflows-manager.tsx | 14 +- .../__tests__/copilot-panel.test.tsx | 10 +- .../conversation/attribute-value-input.tsx | 1 - .../conversation-attributes-editor.tsx | 4 +- .../admin/conversation/copilot-panel.tsx | 24 +- .../admin/feedback/ai-summary-card.tsx | 2 +- .../admin/feedback/saved-views-menu.tsx | 4 +- .../admin/feedback/similar-posts-card.tsx | 4 +- .../__tests__/create-ticket-dialog.test.tsx | 9 +- .../__tests__/inbox-detail-panel.test.tsx | 21 +- .../admin/inbox/inbox-detail-panel.tsx | 12 +- .../components/admin/inbox/ticket-links.tsx | 2 +- .../admin/inbox/ticket-tracker-links.tsx | 4 +- .../settings/boards/create-board-dialog.tsx | 12 +- .../settings/branding/portal-nav-editor.tsx | 2 +- .../shared/destination-picker.tsx | 9 +- .../shared/notification-channel-router.tsx | 11 +- .../__tests__/mcp-server-settings.test.tsx | 4 +- .../__tests__/claims-table.test.tsx | 4 +- .../__tests__/provider-detail-page.test.tsx | 17 +- .../__tests__/user-details-card.test.tsx | 14 +- .../identity-providers/claims-table.tsx | 8 +- .../provider-detail-page.tsx | 4 +- .../provider-kind-picker.tsx | 8 +- .../identity-providers/sign-in-card.tsx | 4 +- .../tags/__tests__/ai-backfill-card.test.tsx | 8 +- .../admin/settings/tags/tag-list.tsx | 16 +- .../users/__tests__/user-detail.test.tsx | 4 +- .../admin/users/__tests__/users-list.test.tsx | 34 +- .../admin/users/block-person-control.tsx | 2 +- .../admin/users/merge-lead-control.tsx | 2 +- .../components/admin/users/user-detail.tsx | 6 +- .../agent-conversation-thread.test.tsx | 4 +- .../agent-conversation-thread.tsx | 6 +- .../public/__tests__/comment-content.test.tsx | 20 + .../public/__tests__/portal-header.test.tsx | 7 +- .../components/public/author-hover-card.tsx | 2 +- .../public/post-detail/metadata-sidebar.tsx | 2 +- .../shared/__tests__/emoji-picker.test.tsx | 39 ++ .../src/components/shared/emoji-picker.tsx | 66 +++- .../src/components/shared/reaction-chip.tsx | 2 +- .../ui/__tests__/dropdown-menu.test.tsx | 25 ++ .../__tests__/emoji-suggestion-list.test.tsx | 82 ++++ .../ui/__tests__/highlight-query.test.ts | 44 +++ .../ui/__tests__/mention-picker.test.tsx | 15 + .../rich-text-editor-extensions.test.ts | 41 ++ .../ui/__tests__/slash-menu-list.test.tsx | 68 ++++ .../ui/__tests__/suggestion-list-keys.test.ts | 81 ++++ apps/web/src/components/ui/alert-dialog.tsx | 101 +++-- apps/web/src/components/ui/as-child.ts | 128 ++++++ apps/web/src/components/ui/autocomplete.tsx | 2 +- apps/web/src/components/ui/avatar.tsx | 67 +--- apps/web/src/components/ui/badge.tsx | 12 +- apps/web/src/components/ui/button.tsx | 39 +- apps/web/src/components/ui/checkbox.tsx | 26 +- apps/web/src/components/ui/collapsible.tsx | 74 ++-- apps/web/src/components/ui/combobox.tsx | 5 +- apps/web/src/components/ui/context-menu.tsx | 133 ++++--- apps/web/src/components/ui/dialog.tsx | 81 +++- apps/web/src/components/ui/dropdown-menu.tsx | 240 ++++++----- apps/web/src/components/ui/form.tsx | 25 +- .../web/src/components/ui/highlight-query.tsx | 55 +++ apps/web/src/components/ui/label.tsx | 5 +- .../src/components/ui/mention-extension.ts | 11 +- apps/web/src/components/ui/mention-picker.tsx | 47 +-- apps/web/src/components/ui/popover.tsx | 117 ++++-- apps/web/src/components/ui/radio-group.tsx | 24 +- .../src/components/ui/rich-text-editor.tsx | 373 ++++++++++-------- apps/web/src/components/ui/scroll-area.tsx | 24 +- .../src/components/ui/searchable-input.tsx | 4 +- apps/web/src/components/ui/select.tsx | 270 ++++++++++--- apps/web/src/components/ui/sheet.tsx | 82 ++-- apps/web/src/components/ui/slider.tsx | 61 +-- .../src/components/ui/suggestion-list-keys.ts | 68 ++++ .../web/src/components/ui/suggestion-popup.ts | 61 +++ apps/web/src/components/ui/switch.tsx | 46 ++- apps/web/src/components/ui/tabs.tsx | 20 +- apps/web/src/components/ui/tooltip.tsx | 82 ++-- apps/web/src/globals.css | 14 +- .../server/__tests__/markdown-tiptap.test.ts | 20 + .../shared/__tests__/content-emoji.test.ts | 26 ++ .../lib/shared/__tests__/content-html.test.ts | 29 ++ .../__tests__/emoji-recommendations.test.ts | 108 +++++ apps/web/src/lib/shared/content-emoji.ts | 11 +- .../src/lib/shared/emoji-recommendations.ts | 146 +++++++ apps/web/src/routes/admin.tsx | 2 +- .../web/src/routes/admin/automation.agent.tsx | 18 +- .../src/routes/admin/automation.copilot.tsx | 12 +- apps/web/src/routes/admin/settings.widget.tsx | 2 +- bun.lock | 308 +-------------- vitest.setup.ts | 5 + 111 files changed, 2935 insertions(+), 1359 deletions(-) create mode 100644 apps/web/e2e/tests/admin/base-ui-components.spec.ts create mode 100644 apps/web/e2e/tests/admin/emoji-shortcuts-verify.spec.ts create mode 100644 apps/web/e2e/tests/public/base-ui-components.spec.ts create mode 100644 apps/web/src/components/shared/__tests__/emoji-picker.test.tsx create mode 100644 apps/web/src/components/ui/__tests__/dropdown-menu.test.tsx create mode 100644 apps/web/src/components/ui/__tests__/emoji-suggestion-list.test.tsx create mode 100644 apps/web/src/components/ui/__tests__/highlight-query.test.ts create mode 100644 apps/web/src/components/ui/__tests__/slash-menu-list.test.tsx create mode 100644 apps/web/src/components/ui/__tests__/suggestion-list-keys.test.ts create mode 100644 apps/web/src/components/ui/as-child.ts create mode 100644 apps/web/src/components/ui/highlight-query.tsx create mode 100644 apps/web/src/components/ui/suggestion-list-keys.ts create mode 100644 apps/web/src/lib/shared/__tests__/content-emoji.test.ts create mode 100644 apps/web/src/lib/shared/__tests__/emoji-recommendations.test.ts create mode 100644 apps/web/src/lib/shared/emoji-recommendations.ts diff --git a/apps/web/e2e/tests/admin/base-ui-components.spec.ts b/apps/web/e2e/tests/admin/base-ui-components.spec.ts new file mode 100644 index 0000000000..adb9e63fb4 --- /dev/null +++ b/apps/web/e2e/tests/admin/base-ui-components.spec.ts @@ -0,0 +1,202 @@ +import { expect, test, type Page } from '@playwright/test' +import { waitForHydration } from '../../utils/helpers' + +/** + * Site-wide usability pass for the Radix → Base UI migration. + * Opens each primitive on a real admin page the way an admin would, then + * checks the overlay is usable (visible, labelled, dismissible). + */ +async function waitForAdmin(page: Page) { + await expect(page.getByRole('navigation').first()).toBeVisible({ timeout: 15_000 }) +} + +test.describe('Base UI component usability', () => { + test.use({ viewport: { width: 1920, height: 1080 } }) + test.describe.configure({ timeout: 60_000 }) + + test('tooltip, dropdown, and popover open and dismiss', async ({ page }) => { + await page.goto('/admin/feedback') + await waitForAdmin(page) + + const switcher = page.getByRole('button', { name: 'Switch workspace' }) + if ((await switcher.count()) > 0) { + await waitForHydration(switcher) + await switcher.hover() + await expect(page.locator('[data-slot="tooltip-content"]').first()).toBeVisible({ + timeout: 5_000, + }) + await switcher.click() + await expect(page.getByRole('menu')).toBeVisible() + await expect(page.getByRole('menuitem').first()).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.getByRole('menu')).toHaveCount(0) + } + + const bell = page.getByRole('button', { name: /notifications/i }) + await waitForHydration(bell) + await bell.click() + await expect(page.locator('[data-slot="popover-content"]')).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0) + }) + + test('switch, select, slider, and styled links stay usable', async ({ page }) => { + await page.goto('/admin/settings/general') + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible({ timeout: 15_000 }) + const productSwitch = page.locator('#product-changelog') + await waitForHydration(productSwitch) + const checked = await productSwitch.getAttribute('aria-checked') + expect(checked === 'true' || checked === 'false').toBeTruthy() + + await page.goto('/admin/settings/widget') + await expect(page.getByRole('heading', { name: 'Widget' }).first()).toBeVisible({ + timeout: 15_000, + }) + const position = page.locator('#widget-position') + await waitForHydration(position) + const shown = await position.innerText() + await position.click() + const listbox = page.locator('[data-slot="select-content"], [role="listbox"]').first() + await expect(listbox).toBeVisible() + await expect(page.getByRole('option').first()).toBeVisible() + await page.keyboard.press('Escape') + await expect(listbox).toBeHidden() + await expect(position).toHaveText(shown) + + const install = page.getByRole('link', { name: /Install widget|View installation/ }) + await expect(install).toBeVisible() + await expect(install).not.toHaveAttribute('role', 'button') + + await page.goto('/admin/settings/portal') + await expect(page.getByRole('heading', { name: 'Portal' })).toBeVisible({ timeout: 15_000 }) + const slider = page.getByRole('slider') + await expect(slider).toBeVisible() + await expect(slider).toHaveAttribute('aria-valuenow') + }) + + test('tabs, dialog, radio, and checkbox stay usable', async ({ page }) => { + await page.goto('/admin/settings/boards') + const newBoard = page.getByRole('button', { name: 'New board' }) + await waitForHydration(newBoard) + await newBoard.click() + const createDialog = page.getByRole('dialog') + await expect(createDialog).toBeVisible() + await expect(createDialog.getByLabel('Board name')).toBeVisible() + await page.keyboard.press('Escape') + await expect(createDialog).toBeHidden() + + const firstBoard = page.locator('a[href*="/admin/settings/boards/"]').first() + await expect(firstBoard).toBeVisible() + await firstBoard.click() + const general = page.getByRole('tab', { name: 'General' }) + const access = page.getByRole('tab', { name: 'Access' }) + await waitForHydration(general) + await expect(general).toHaveAttribute('data-active') + await access.click() + await expect(access).toHaveAttribute('data-active') + await expect(general).not.toHaveAttribute('data-active') + + await page.goto('/admin/settings/tags') + const addTag = page.getByText('Add new tag') + await waitForHydration(addTag) + await addTag.click() + const tagDialog = page.getByRole('dialog') + await expect(tagDialog).toBeVisible() + const portal = tagDialog.getByRole('radio', { name: /^portal$/i }) + const internal = tagDialog.getByRole('radio', { name: /^internal$/i }) + await expect(portal).toHaveAttribute('aria-checked', 'true') + await internal.click() + await expect(internal).toHaveAttribute('aria-checked', 'true') + await tagDialog.getByRole('button', { name: /cancel/i }).click() + await expect(tagDialog).toBeHidden() + + await page.goto('/admin/settings/office-hours') + await expect(page.getByRole('heading', { name: 'Office Hours' })).toBeVisible({ + timeout: 15_000, + }) + await expect(page.locator('#office-hours-enabled')).toBeVisible() + if ((await page.getByRole('checkbox').count()) > 0) { + await expect(page.getByRole('checkbox').first()).toBeEnabled() + } + }) + + test('menu checkbox items, alert dialog, and dropdown stay usable', async ({ page }) => { + await page.goto('/admin/users') + await expect(page.getByPlaceholder('Search users...')).toBeVisible({ timeout: 15_000 }) + const columns = page.getByRole('button', { name: 'Columns' }) + await waitForHydration(columns) + await columns.click() + const checkboxItem = page.getByRole('menuitemcheckbox').first() + await expect(checkboxItem).toBeVisible() + const before = await checkboxItem.getAttribute('aria-checked') + await checkboxItem.click() + await expect(checkboxItem).not.toHaveAttribute('aria-checked', before ?? '') + await checkboxItem.click() + await expect(checkboxItem).toHaveAttribute('aria-checked', before ?? '') + await page.keyboard.press('Escape') + + await page.goto('/admin/notifications') + await expect(page.getByRole('heading', { name: /notification/i }).first()).toBeVisible({ + timeout: 15_000, + }) + const allTab = page.getByRole('tab', { name: 'All' }) + const unreadTab = page.getByRole('tab', { name: /unread/i }) + if ((await allTab.count()) > 0) { + await waitForHydration(unreadTab) + await unreadTab.click() + await expect(unreadTab).toHaveAttribute('data-active') + await allTab.click() + await expect(allTab).toHaveAttribute('data-active') + } + const moreActions = page.getByRole('button', { name: 'More notification actions' }) + await waitForHydration(moreActions) + await moreActions.click() + await page.getByRole('menuitem', { name: 'Archive all read' }).click() + const alert = page.getByRole('alertdialog') + await expect(alert).toBeVisible() + await expect(alert.getByText('Archive all read notifications?')).toBeVisible() + await alert.getByRole('button', { name: 'Cancel' }).click() + await expect(alert).toBeHidden() + }) + + test('inbox tabs and collapsible work on a conversation', async ({ page }) => { + await page.goto('/admin/inbox') + await waitForAdmin(page) + const row = page.locator('a[href*="inbox"], [data-testid="inbox-row"]').first() + const textRow = page.getByRole('main').getByRole('button').first() + const target = (await row.count()) > 0 ? row : textRow + if ((await target.count()) === 0) return + + await target.click({ timeout: 8_000 }).catch(() => {}) + const details = page.getByRole('tab', { name: 'Details' }) + if ((await details.count()) === 0) return + + await expect(details).toBeVisible() + await waitForHydration(details) + const copilot = page.getByRole('tab', { name: /copilot/i }) + if ((await copilot.count()) > 0) { + await waitForHydration(copilot) + await copilot.click() + await expect(copilot).toHaveAttribute('data-active') + await details.click() + await expect(details).toHaveAttribute('data-active') + } + + const collapse = page.getByRole('button', { name: /properties|activity|similar/i }).first() + if ((await collapse.count()) > 0) { + await collapse.click() + } + }) + + test('sheet opens on a phone viewport', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/admin/feedback') + const menu = page.getByRole('button', { name: 'Open menu' }) + await waitForHydration(menu) + await menu.click() + const sheet = page.locator('[data-slot="sheet-content"]').or(page.getByRole('dialog')).first() + await expect(sheet).toBeVisible() + await page.keyboard.press('Escape') + await expect(sheet).toBeHidden() + }) +}) diff --git a/apps/web/e2e/tests/admin/boards.spec.ts b/apps/web/e2e/tests/admin/boards.spec.ts index f186f5267f..0e375712b1 100644 --- a/apps/web/e2e/tests/admin/boards.spec.ts +++ b/apps/web/e2e/tests/admin/boards.spec.ts @@ -89,7 +89,7 @@ test.describe('Admin Board Management', () => { test('can access board general settings', async ({ page }) => { await openFirstBoard(page) - await expect(page.getByRole('tab', { name: 'General' })).toHaveAttribute('data-state', 'active') + await expect(page.getByRole('tab', { name: 'General' })).toHaveAttribute('data-active') await expect(page.getByRole('textbox', { name: 'Board name', exact: true })).toBeVisible() await expect(page.getByRole('navigation', { name: 'Breadcrumb' })).toBeVisible() await expect(page.getByTestId('board-switcher')).toHaveCount(0) @@ -627,7 +627,7 @@ test.describe('Board Settings Tabs', () => { const generalTab = page.getByRole('tab', { name: 'General' }) if ((await generalTab.count()) === 0) return - await expect(generalTab).toHaveAttribute('data-state', 'active') + await expect(generalTab).toHaveAttribute('data-active') }) test('active tab button is visually distinct after switching', async ({ page }) => { @@ -637,11 +637,8 @@ test.describe('Board Settings Tabs', () => { await accessTab.click() await page.waitForLoadState('networkidle') - await expect(accessTab).toHaveAttribute('data-state', 'active') - await expect(page.getByRole('tab', { name: 'General' })).toHaveAttribute( - 'data-state', - 'inactive' - ) + await expect(accessTab).toHaveAttribute('data-active') + await expect(page.getByRole('tab', { name: 'General' })).not.toHaveAttribute('data-active') }) }) diff --git a/apps/web/e2e/tests/admin/emoji-shortcuts-verify.spec.ts b/apps/web/e2e/tests/admin/emoji-shortcuts-verify.spec.ts new file mode 100644 index 0000000000..96eafb9f18 --- /dev/null +++ b/apps/web/e2e/tests/admin/emoji-shortcuts-verify.spec.ts @@ -0,0 +1,110 @@ +import { mkdirSync } from 'node:fs' +import { test, expect } from '@playwright/test' +import { setSupportSurfaces, seedConversation } from '../../utils/db-helpers' + +const SHOT_DIR = '/tmp/qb-emoji-verify' + +test.describe('Emoji shortcut visual verification', () => { + test.use({ viewport: { width: 1440, height: 900 } }) + test.setTimeout(90_000) + + test.beforeAll(() => { + mkdirSync(SHOT_DIR, { recursive: true }) + setSupportSurfaces(true) + }) + + test('captures typeahead, Tab insert, sent glyph, recents, smile grid, slash', async ({ + page, + }) => { + const seeded = seedConversation(`Emoji verify ${Date.now()}`) + await page.goto('/admin/inbox') + + const row = page.getByText(seeded.messages[1]).first() + await expect(row).toBeVisible({ timeout: 15000 }) + await expect(async () => { + await row.click() + await expect(page).toHaveURL(new RegExp(`i=${seeded.conversationId}`), { timeout: 2000 }) + }).toPass({ timeout: 15000 }) + + const composer = page.locator('.ProseMirror[contenteditable="true"]').first() + await composer.click() + await page.keyboard.type('Fingers crossed ') + await page.keyboard.type(':') + const emojiMenu = page.locator('[data-emoji-picker]') + await expect(emojiMenu).toBeVisible({ timeout: 5000 }) + await page.screenshot({ path: `${SHOT_DIR}/01-bare-colon-popular.png` }) + await emojiMenu.screenshot({ path: `${SHOT_DIR}/01b-bare-colon-menu.png` }) + + await page.keyboard.type('finger') + await expect(emojiMenu).toBeVisible() + await expect(emojiMenu.locator('[data-emoji-shortcode="crossed_fingers"]')).toBeVisible({ + timeout: 5000, + }) + await expect(emojiMenu.locator('[data-query-match]').first()).toHaveText('finger') + await expect + .poll(async () => { + const menuBox = await emojiMenu.boundingBox() + const sendBox = await page.getByRole('button', { name: 'Send reply' }).boundingBox() + return Boolean(menuBox && sendBox && menuBox.y + menuBox.height < sendBox.y) + }) + .toBe(true) + await page.screenshot({ path: `${SHOT_DIR}/02-finger-typeahead.png` }) + await emojiMenu.screenshot({ path: `${SHOT_DIR}/02b-finger-menu.png` }) + + await page.keyboard.press('Tab') + await expect(emojiMenu).toBeHidden({ timeout: 5000 }) + await expect(composer).toContainText('🤞') + await page.screenshot({ path: `${SHOT_DIR}/03-tab-inserted-glyph.png` }) + + await page.getByRole('button', { name: 'Send reply' }).click() + const sentBubble = page + .locator('[data-inbox-thread], main, [class*="thread"]') + .locator('text=Fingers crossed') + .last() + await expect(page.getByText('Fingers crossed').last()).toBeVisible({ timeout: 10000 }) + await expect(page.getByText('Fingers crossed').last()).toContainText('🤞') + await page.screenshot({ path: `${SHOT_DIR}/04-sent-message-glyph.png` }) + await sentBubble.screenshot({ path: `${SHOT_DIR}/04b-sent-bubble.png` }).catch(() => {}) + + await composer.click() + await page.keyboard.type(':') + await expect(emojiMenu).toBeVisible({ timeout: 5000 }) + await expect(emojiMenu.getByText('Recent', { exact: true })).toBeVisible({ timeout: 5000 }) + await page.screenshot({ path: `${SHOT_DIR}/05-bare-colon-recent.png` }) + await emojiMenu.screenshot({ path: `${SHOT_DIR}/05b-recent-menu.png` }) + await page.keyboard.press('Escape') + + await page.getByRole('button', { name: 'Insert emoji' }).click() + const smilePanel = page.getByRole('dialog').or(page.locator('[data-slot="popover-content"]')) + await expect(smilePanel.getByText('Recent', { exact: true })).toBeVisible({ timeout: 5000 }) + await expect(smilePanel.getByText('Popular', { exact: true })).toBeVisible() + await expect(smilePanel.getByText('🤞').first()).toBeVisible() + await page.screenshot({ path: `${SHOT_DIR}/06-smile-grid-recent-popular.png` }) + await smilePanel.first().screenshot({ path: `${SHOT_DIR}/06b-smile-panel.png` }) + await page.keyboard.press('Escape') + + await composer.click() + await page.keyboard.type('/') + await expect(page.getByRole('button', { name: 'Bullet List' })).toBeVisible({ timeout: 5000 }) + const slashPopup = page.locator('[data-editor-suggestion]').last() + await expect + .poll(async () => { + const slashBox = await slashPopup.boundingBox() + const sendAfterSlash = await page.getByRole('button', { name: 'Send reply' }).boundingBox() + return Boolean( + slashBox && sendAfterSlash && slashBox.y + slashBox.height < sendAfterSlash.y + ) + }) + .toBe(true) + await page.screenshot({ path: `${SHOT_DIR}/07-slash-menu.png` }) + await page.keyboard.press('Escape') + + await composer.click() + await page.keyboard.type('Typed shortcode ') + await page.keyboard.type(':crossed_fingers:') + await expect(composer).toContainText('🤞') + await page.getByRole('button', { name: 'Send reply' }).click() + await expect(page.getByText('Typed shortcode').first()).toBeVisible({ timeout: 10000 }) + await page.screenshot({ path: `${SHOT_DIR}/08-typed-shortcode-sent.png` }) + }) +}) diff --git a/apps/web/e2e/tests/admin/post-management.spec.ts b/apps/web/e2e/tests/admin/post-management.spec.ts index 3582366eaf..d13ec9ce80 100644 --- a/apps/web/e2e/tests/admin/post-management.spec.ts +++ b/apps/web/e2e/tests/admin/post-management.spec.ts @@ -145,7 +145,7 @@ test.describe('Admin Post Management', () => { // Find post items - looking for clickable elements in the list const postList = page .locator('[data-testid="post-item"]') - .or(page.locator('button[data-state]').filter({ has: page.getByText(/.+/) })) + .or(page.locator('[data-testid="post-item"]')) if ((await postList.count()) > 0) { await postList.first().click() @@ -467,7 +467,7 @@ test.describe('Admin Post Management - Status Transitions', () => { await statusBadgeButton.click() // Popover opens with status options - const popover = page.locator('[data-radix-popper-content-wrapper]') + const popover = page.locator('[data-slot="popover-content"]') await expect(popover).toBeVisible({ timeout: 5000 }) // Pick a status that is different from the current one @@ -540,7 +540,7 @@ test.describe('Admin Post Management - Status Transitions', () => { // Open status popover and pick a different option await statusBadgeButton.click() - const popover = page.locator('[data-radix-popper-content-wrapper]') + const popover = page.locator('[data-slot="popover-content"]') await expect(popover).toBeVisible({ timeout: 5000 }) const statusOptions = popover.locator('button') @@ -604,7 +604,7 @@ test.describe('Admin Post Management - Status Transitions', () => { const initialStatusText = (await statusBadgeButton.textContent()) ?? '' await statusBadgeButton.click() - const popover = page.locator('[data-radix-popper-content-wrapper]') + const popover = page.locator('[data-slot="popover-content"]') await expect(popover).toBeVisible({ timeout: 5000 }) const statusOptions = popover.locator('button') @@ -683,7 +683,10 @@ test.describe('Admin Post Management - Post Detail Panel Accuracy', () => { // Vote count is rendered as a tabular-nums span next to the "Upvotes" label // MetadataSidebar admin mode: {voteCount} - const upvotesRow = sidebar.locator('div').filter({ hasText: /Upvotes/ }).first() + const upvotesRow = sidebar + .locator('div') + .filter({ hasText: /Upvotes/ }) + .first() await expect(upvotesRow).toBeVisible() // The vote count is a number — find a span that contains only digits @@ -744,7 +747,10 @@ test.describe('Admin Post Management - Post Detail Panel Accuracy', () => { // Board name appears as a button (editable in admin mode) or plain span // Either way there must be some non-empty text next to the Board label - const boardRow = sidebar.locator('div').filter({ hasText: /^Board/ }).first() + const boardRow = sidebar + .locator('div') + .filter({ hasText: /^Board/ }) + .first() await expect(boardRow).toBeVisible() // The board name text must be non-empty @@ -808,7 +814,10 @@ test.describe('Admin Post Management - Post Detail Panel Accuracy', () => { await expect(sidebar.getByText('Author')).toBeVisible() // Author name is rendered as a span with text-sm font-medium next to an Avatar - const authorRow = sidebar.locator('div').filter({ hasText: /^Author/ }).first() + const authorRow = sidebar + .locator('div') + .filter({ hasText: /^Author/ }) + .first() await expect(authorRow).toBeVisible() // There should be a non-empty name or "Anonymous" fallback @@ -852,12 +861,13 @@ test.describe('Admin Post Management - Filter + Pagination Accuracy', () => { const emptyState = page.locator('text=/no posts|no results/i') // Either posts exist (filtered) or empty state appears — both are valid results - const hasContent = - (await postCards.count()) > 0 || (await emptyState.count()) > 0 + const hasContent = (await postCards.count()) > 0 || (await emptyState.count()) > 0 expect(hasContent).toBe(true) // The active filters bar should show the selected board name as a chip - const activeFiltersBar = page.locator('[class*="ActiveFilters"], [data-testid="active-filters"]') + const activeFiltersBar = page.locator( + '[class*="ActiveFilters"], [data-testid="active-filters"]' + ) // Board filter chip: text contains the board name (trimmed) const boardChip = activeFiltersBar.first().getByText(boardName.trim(), { exact: false }) await expect(activeFiltersBar.first()).toBeVisible() @@ -1079,7 +1089,10 @@ test.describe('Admin Post Management - Edit Flow', () => { await expect(sidebar.getByText('Board')).toBeVisible() // Board name is a clickable button in admin mode - const boardRow = sidebar.locator('div').filter({ hasText: /^Board/ }).first() + const boardRow = sidebar + .locator('div') + .filter({ hasText: /^Board/ }) + .first() const boardButton = boardRow.locator('button').first() if ((await boardButton.count()) === 0) { @@ -1093,11 +1106,13 @@ test.describe('Admin Post Management - Edit Flow', () => { await boardButton.click() // Board popover opens with list of boards - const boardPopover = page.locator('[data-radix-popper-content-wrapper]') + const boardPopover = page.locator('[data-slot="popover-content"]') await expect(boardPopover).toBeVisible({ timeout: 5000 }) // Pick a different board - const boardChoices = boardPopover.locator('button').filter({ hasNot: boardPopover.locator('svg.lucide-check') }) + const boardChoices = boardPopover + .locator('button') + .filter({ hasNot: boardPopover.locator('svg.lucide-check') }) let newBoardName = '' for (let i = 0; i < (await boardChoices.count()); i++) { const choiceText = ((await boardChoices.nth(i).textContent()) ?? '').trim() @@ -1137,7 +1152,10 @@ test.describe('Admin Post Management - Edit Flow', () => { const reopenedSidebar = reopenedModal.locator('aside') await expect(reopenedSidebar).toBeVisible({ timeout: 5000 }) - const persistedBoardRow = reopenedSidebar.locator('div').filter({ hasText: /^Board/ }).first() + const persistedBoardRow = reopenedSidebar + .locator('div') + .filter({ hasText: /^Board/ }) + .first() const persistedBoardText = ((await persistedBoardRow.textContent()) ?? '') .replace(/^Board/, '') .trim() diff --git a/apps/web/e2e/tests/admin/settings-tags.spec.ts b/apps/web/e2e/tests/admin/settings-tags.spec.ts index 3d8ac31d94..98f4c74ebd 100644 --- a/apps/web/e2e/tests/admin/settings-tags.spec.ts +++ b/apps/web/e2e/tests/admin/settings-tags.spec.ts @@ -189,7 +189,7 @@ test.describe('Admin Tags Settings', () => { await colorDots.first().click() // Color picker popover should open - const popover = page.locator('[data-radix-popover-content]') + const popover = page.locator('[data-slot="popover-content"]') if ((await popover.count()) > 0) { await expect(popover).toBeVisible() diff --git a/apps/web/e2e/tests/admin/settings-user-attributes.spec.ts b/apps/web/e2e/tests/admin/settings-user-attributes.spec.ts index 7bd6433007..2a9e07ddf6 100644 --- a/apps/web/e2e/tests/admin/settings-user-attributes.spec.ts +++ b/apps/web/e2e/tests/admin/settings-user-attributes.spec.ts @@ -126,7 +126,7 @@ test.describe('Admin User Attributes Settings', () => { // Options should be visible const optionContainer = page .locator('[role="listbox"]') - .or(page.locator('[data-radix-select-content]')) + .or(page.locator('[data-slot="select-content"]')) if ((await optionContainer.count()) > 0) { await expect(optionContainer.getByText('Text')).toBeVisible() await expect(optionContainer.getByText('Number')).toBeVisible() @@ -369,7 +369,7 @@ test.describe('Admin User Attributes Settings', () => { const optionContainer = page .locator('[role="listbox"]') - .or(page.locator('[data-radix-select-content]')) + .or(page.locator('[data-slot="select-content"]')) if ((await optionContainer.count()) > 0) { const currencyOption = optionContainer.getByText('Currency') diff --git a/apps/web/e2e/tests/admin/statuses.spec.ts b/apps/web/e2e/tests/admin/statuses.spec.ts index 25b15336ef..2ac414997c 100644 --- a/apps/web/e2e/tests/admin/statuses.spec.ts +++ b/apps/web/e2e/tests/admin/statuses.spec.ts @@ -71,7 +71,7 @@ test.describe('Admin Status Management', () => { await colorButtons.first().click() // May show popover with color options - const colorPopover = page.locator('[data-radix-popover-content]') + const colorPopover = page.locator('[data-slot="popover-content"]') if ((await colorPopover.count()) > 0) { await expect(colorPopover).toBeVisible() @@ -89,14 +89,14 @@ test.describe('Admin Status Management', () => { const firstToggle = roadmapToggles.first() // Get current state - const isChecked = await firstToggle.getAttribute('data-state') + const isChecked = await firstToggle.getAttribute('aria-checked') // Click to toggle await firstToggle.click() // State should change await page.waitForTimeout(500) - const newState = await firstToggle.getAttribute('data-state') + const newState = await firstToggle.getAttribute('aria-checked') // Should be different from initial state expect(newState).not.toBe(isChecked) @@ -107,7 +107,9 @@ test.describe('Admin Status Management', () => { // Default status is indicated by a LockClosedIcon (Heroicons, not Lucide). // The icon renders with className "h-3 w-3 text-muted-foreground" which is unique // to the lock icon within the status list on this page. - const defaultIndicator = page.locator('svg.h-3.w-3.text-muted-foreground').or(page.getByText(/default/i)) + const defaultIndicator = page + .locator('svg.h-3.w-3.text-muted-foreground') + .or(page.getByText(/default/i)) await expect(defaultIndicator.first()).toBeVisible({ timeout: 10000 }) }) diff --git a/apps/web/e2e/tests/admin/users-segments.spec.ts b/apps/web/e2e/tests/admin/users-segments.spec.ts index a009b1b50d..919be3b598 100644 --- a/apps/web/e2e/tests/admin/users-segments.spec.ts +++ b/apps/web/e2e/tests/admin/users-segments.spec.ts @@ -165,7 +165,7 @@ test.describe('Admin Users Segments', () => { const optionContainer = page .locator('[role="listbox"]') - .or(page.locator('[data-radix-select-content]')) + .or(page.locator('[data-slot="select-content"]')) if ((await optionContainer.count()) > 0) { await expect(optionContainer.getByText('Email Domain')).toBeVisible() @@ -195,7 +195,7 @@ test.describe('Admin Users Segments', () => { await comboboxes.nth(1).click() const optionContainer = page .locator('[role="listbox"]') - .or(page.locator('[data-radix-select-content]')) + .or(page.locator('[data-slot="select-content"]')) if ((await optionContainer.count()) > 0) { const postCountOption = optionContainer.getByText('Post Count') @@ -322,7 +322,7 @@ test.describe('Admin Users Segments', () => { const optionContainer = page .locator('[role="listbox"]') - .or(page.locator('[data-radix-select-content]')) + .or(page.locator('[data-slot="select-content"]')) if ((await optionContainer.count()) > 0) { await expect(optionContainer.getByText(/all/i).first()).toBeVisible() diff --git a/apps/web/e2e/tests/public/base-ui-components.spec.ts b/apps/web/e2e/tests/public/base-ui-components.spec.ts new file mode 100644 index 0000000000..72c2c0626d --- /dev/null +++ b/apps/web/e2e/tests/public/base-ui-components.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test' +import { waitForHydration } from '../../utils/helpers' + +/** + * Public-portal usability pass for the Radix → Base UI migration. + */ +test.describe('Public Base UI component usability', () => { + test.describe.configure({ timeout: 45_000 }) + + test('filters, menus, and styled links stay usable', async ({ page }) => { + await page.goto('/') + const filter = page.getByRole('button', { name: /^filter$/i }) + await waitForHydration(filter) + await filter.click() + const overlay = page.locator( + '[data-slot="popover-content"], [role="menu"], [data-slot="dropdown-menu-content"]' + ) + await expect(overlay.first()).toBeVisible() + await expect(page.getByRole('button', { name: /^Status$/i })).toBeVisible() + await page.keyboard.press('Escape') + await expect(overlay.first()).toBeHidden() + + await page.goto('/changelog') + const rss = page.locator('a[href="/changelog/feed"]') + await expect(rss).toBeVisible({ timeout: 10_000 }) + await expect(rss).not.toHaveAttribute('role', 'button') + + const subscribe = page.getByRole('button', { name: /subscri/i }) + if ((await subscribe.count()) > 0) { + await subscribe.first().click() + const menu = page.getByRole('menu') + if ((await menu.count()) > 0) { + await expect(menu).toBeVisible() + await page.keyboard.press('Escape') + } + } + }) + + test('roadmap filter popover opens', async ({ page }) => { + await page.goto('/roadmap') + const filter = page.getByRole('button', { name: /filter|board|status/i }).first() + if ((await filter.count()) === 0) { + test.skip() + return + } + await waitForHydration(filter) + await filter.click() + const overlay = page.locator('[data-slot="popover-content"], [role="dialog"]') + if ((await overlay.count()) > 0) { + await expect(overlay.first()).toBeVisible() + await page.keyboard.press('Escape') + } + }) +}) diff --git a/apps/web/e2e/tests/public/post-list.spec.ts b/apps/web/e2e/tests/public/post-list.spec.ts index cf606d3284..7cf7ee4b7e 100644 --- a/apps/web/e2e/tests/public/post-list.spec.ts +++ b/apps/web/e2e/tests/public/post-list.spec.ts @@ -566,7 +566,7 @@ test.describe('Public Post List', () => { await filterButton.click() // The checkbox for "open" status should be checked - const openCheckbox = page.locator('button[role="checkbox"][data-state="checked"]') + const openCheckbox = page.locator('[role="checkbox"][data-checked]') await expect(openCheckbox.first()).toBeVisible() }) diff --git a/apps/web/e2e/tests/public/roadmap.spec.ts b/apps/web/e2e/tests/public/roadmap.spec.ts index c59b54445a..4ef4362406 100644 --- a/apps/web/e2e/tests/public/roadmap.spec.ts +++ b/apps/web/e2e/tests/public/roadmap.spec.ts @@ -38,7 +38,7 @@ test.describe('Public Roadmap', () => { test('empty state is shown when no roadmaps exist', async ({ page }) => { const emptyState = page.getByText(/no roadmaps available/i) - const columns = page.locator('[data-radix-scroll-area-viewport]') + const columns = page.locator('[data-slot="scroll-area-viewport"]') if ((await emptyState.count()) > 0) { await expect(emptyState).toBeVisible() diff --git a/apps/web/e2e/utils/helpers.ts b/apps/web/e2e/utils/helpers.ts index 5ced9c3f92..5e2ec04e3f 100644 --- a/apps/web/e2e/utils/helpers.ts +++ b/apps/web/e2e/utils/helpers.ts @@ -1,4 +1,31 @@ -import { Page, expect } from '@playwright/test' +import { Page, Locator, expect } from '@playwright/test' + +/** + * Wait until a control is hydrated (React has attached event handlers). + * + * Visibility alone is not enough: pages are server-rendered, so a button can + * be visible seconds before hydration attaches its handlers — a click in that + * window is silently swallowed (no error, nothing opens). With SSE streams + * keeping the network hot, `networkidle` never fires, so wait on the control + * itself: React 19 marks hydrated DOM nodes with `__reactProps$…` keys. + * + * Call this on the exact control about to be clicked — selective hydration + * means one hydrated region says nothing about another. + * + * Suites that already gate on `networkidle` after `goto` rarely need this + * (idle network implies the bundles loaded well before first paint), but + * specs that interact right after `toBeVisible` — or retry-free single + * clicks — should wait explicitly. + */ +export async function waitForHydration(target: Locator, timeout = 20000) { + await expect(target).toBeVisible({ timeout }) + await expect + .poll( + () => target.evaluate((el) => Object.keys(el).some((k) => k.startsWith('__reactProps'))), + { timeout, message: 'Timed out waiting for React hydration' } + ) + .toBe(true) +} /** * Wait for a toast notification with specific text diff --git a/apps/web/package.json b/apps/web/package.json index da060ac07e..a49bc66c3c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1032.0", "@aws-sdk/s3-request-presigner": "^3.1032.0", + "@base-ui/react": "^1.8.0", "@better-auth/mcp": "1.7.4", "@better-auth/oauth-provider": "1.7.4", "@dnd-kit/core": "^6.3.1", @@ -55,22 +56,6 @@ "@quackback/email": "workspace:*", "@quackback/ids": "workspace:*", "@quackback/logger": "workspace:*", - "@radix-ui/react-alert-dialog": "^1.1.15", - "@radix-ui/react-avatar": "^1.1.11", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-radio-group": "^1.3.8", - "@radix-ui/react-scroll-area": "^1.2.10", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-tooltip": "1.2.8", "@slack/web-api": "^7.15.1", "@tabler/icons-react": "^3.41.1", "@tailwindcss/typography": "^0.5.19", @@ -131,7 +116,6 @@ "pino": "^10.3.1", "postgres": "^3.4.9", "qrcode": "^1.5.4", - "radix-ui": "^1.6.7", "react": "^19.2.8", "react-day-picker": "^10.0.1", "react-dom": "^19.2.8", diff --git a/apps/web/src/components/admin/admin-author-hover-card.tsx b/apps/web/src/components/admin/admin-author-hover-card.tsx index 4ebf97cfaa..86801bf29b 100644 --- a/apps/web/src/components/admin/admin-author-hover-card.tsx +++ b/apps/web/src/components/admin/admin-author-hover-card.tsx @@ -124,7 +124,7 @@ export function AdminAuthorHoverCard({ className="w-72 p-3" align="start" sideOffset={6} - onOpenAutoFocus={(e) => e.preventDefault()} + initialFocus={false} onMouseEnter={scheduleOpen} onMouseLeave={scheduleClose} > diff --git a/apps/web/src/components/admin/automation/assistant-basics-card.tsx b/apps/web/src/components/admin/automation/assistant-basics-card.tsx index 4c6e3bb354..e773cc9ccb 100644 --- a/apps/web/src/components/admin/automation/assistant-basics-card.tsx +++ b/apps/web/src/components/admin/automation/assistant-basics-card.tsx @@ -186,7 +186,7 @@ export function AssistantVoiceCard() { return (