diff --git a/apps/web/e2e/tests/admin/designer-builder.spec.ts b/apps/web/e2e/tests/admin/designer-builder.spec.ts new file mode 100644 index 0000000000..ffdfb391ee --- /dev/null +++ b/apps/web/e2e/tests/admin/designer-builder.spec.ts @@ -0,0 +1,325 @@ +import { test, expect, type Page, type FrameLocator } from '@playwright/test' + +/** + * The customer portal builder at /design: picking a block and changing it + * with undo, the menu's own menus and renaming in place, the page switcher, + * moving a Home block on the grid by keyboard and by mouse, the Theme sheet, + * words edited in place with a headline's phrase set apart by selection, + * Ask AI's pane and the device switch. + * + * The builder edits one shared draft, so these run in order and each leaves + * the draft as it found it (undo, or discarding what a failed run left). + * Needs the Portal designer Labs switch and the v2 design engine on; skipped + * where the route sends the admin elsewhere. + */ +test.describe.configure({ mode: 'serial' }) + +const COACH_KEY = 'quackback:designer-coach-menu:' + +async function openBuilder(page: Page): Promise { + // The menu's one-time coach mark is taken as seen, so it covers nothing. + await page.addInitScript((prefix) => { + const get = Storage.prototype.getItem + Storage.prototype.getItem = function (key: string) { + return key.startsWith(prefix) ? '1' : get.call(this, key) + } + }, COACH_KEY) + await page.goto('/design?path=/') + test.skip(!new URL(page.url()).pathname.startsWith('/design'), 'The builder is not on here') + const frame = page.frameLocator('iframe[data-designer-canvas]') + await expect(frame.locator('[data-qb-section^="block-"]').first()).toBeVisible({ + timeout: 30_000, + }) + // The page takes clicks as the builder's once its designer styles are in. + await expect(frame.locator('style[data-preview-designer]')).toBeAttached() + return frame +} + +/** Discards what an earlier run may have left in the draft. */ +async function startClean(page: Page) { + const status = page.getByRole('banner', { name: 'Top bar' }).getByRole('button', { + name: /No changes|not live/, + }) + if (/No changes/.test(await status.innerText())) return + await status.click() + await page.getByRole('menuitem', { name: 'Discard draft' }).click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Discard draft' }).click() + await expect(status).toHaveText(/No changes/) +} + +const undoButton = (page: Page) => page.getByRole('button', { name: 'Undo', exact: true }) + +/** Picks a block by keyboard, from the top bar's list of the page's blocks. */ +async function pickFromList(page: Page, name: string) { + await page.getByRole('button', { name: 'Blocks on this page' }).click() + await page.getByRole('menuitem', { name, exact: true }).click() + await expect(page.getByRole('complementary', { name: 'Properties' })).toBeFocused() +} + +const settings = (page: Page) => page.getByRole('complementary', { name: 'Properties' }) +const menuItem = (frame: FrameLocator, target: string) => + frame.locator(`[data-qb-menu="${target}"]`).filter({ visible: true }).first() + +test.beforeEach(async ({ page }) => { + await openBuilder(page) + await startClean(page) +}) + +test('picks a block, changes a setting and undoes it', async ({ page }) => { + // With nothing picked, the panel shows the page. + await expect(settings(page)).toHaveAttribute('data-open', '') + await expect(page.locator('[data-page-panel]')).toBeVisible() + await pickFromList(page, 'Status') + await expect(settings(page)).toHaveAttribute('data-open', '') + // The heading is the words editor, as on the page: plain words, one line. + const heading = settings(page).getByRole('textbox', { name: 'Heading' }) + const before = (await heading.innerText()).trim() + await heading.click() + await page.keyboard.press('ControlOrMeta+a') + await page.keyboard.type('E2E status heading') + await expect(undoButton(page)).not.toHaveAttribute('aria-disabled', 'true') + await heading.press('Enter') + await undoButton(page).click() + await expect(heading).toHaveText(before) + // Escape goes up a level, Home's block to Blocks, then to the page, and from the page closes it. + await settings(page).focus() + await page.keyboard.press('Escape') + await expect(settings(page).getByRole('heading', { name: 'Blocks', level: 2 })).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.locator('[data-page-panel]')).toBeVisible() + await page.keyboard.press('Escape') + await expect(settings(page)).not.toHaveAttribute('data-open', '') + await expect(page.getByRole('button', { name: 'Blocks on this page' })).toBeFocused() +}) + +test('a menu item opens its own menu, where Enter goes to its page', async ({ page }) => { + const frame = page.frameLocator('iframe[data-designer-canvas]') + await menuItem(frame, 'roadmap').click() + const clickMenu = page.locator('[data-menu-click-menu]') + await expect(clickMenu).toBeVisible() + await expect(clickMenu.getByRole('menuitem').first()).toBeFocused() + await page.keyboard.press('Enter') + await expect(page.getByRole('button', { name: /^Page:\s*Roadmap/ })).toBeVisible() +}) + +test('a double-clicked menu item is renamed in place, and undo takes it back', async ({ page }) => { + const frame = page.frameLocator('iframe[data-designer-canvas]') + const item = menuItem(frame, 'changelog') + const name = (await item.innerText()).trim() + await item.dblclick() + const field = page.getByRole('textbox', { name: /Rename|name/i }) + await expect(field).toBeFocused() + await field.fill('What is new') + await page.keyboard.press('Enter') + await expect(menuItem(frame, 'changelog')).toHaveText('What is new') + await undoButton(page).click() + await expect(menuItem(frame, 'changelog')).toHaveText(name) +}) + +test('a phrase of the headline is set apart by selecting it on the page, and undo takes it back', async ({ + page, +}) => { + const frame = page.frameLocator('iframe[data-designer-canvas]') + const headline = frame.locator('[data-qb-words="home.title"]') + await headline.dblclick() + const editor = frame.locator('[data-qb-words-editing] [contenteditable="true"]') + await expect(editor).toBeFocused() + // Every word starts selected: typing replaces them, then the last word is selected. + await page.keyboard.type('Help shape what Acme grows into') + await page.keyboard.press('End') + for (let i = 0; i < ' into'.length; i++) await page.keyboard.press('ArrowLeft') + for (let i = 0; i < 'grows'.length; i++) await page.keyboard.press('Shift+ArrowLeft') + // The format bar offers only the headline's looks. + const bar = page.getByRole('toolbar', { name: 'Set words apart' }) + await expect(bar.getByRole('button')).toHaveText(['Accent', 'Highlight', 'Lighter']) + await bar.getByRole('button', { name: 'Accent' }).click() + await expect(bar.getByRole('button', { name: 'Accent' })).toHaveAttribute('aria-pressed', 'true') + await page.keyboard.press('Enter') + await expect(headline.locator('[data-qb-accent="colour"]')).toHaveText('grows') + await expect(headline).toHaveText('Help shape what Acme grows into') + // The panel shows the same words and mark, with no separate phrase field. + await expect(page.getByText('Phrase to set apart')).toHaveCount(0) + await undoButton(page).hover() + await expect(page.locator('[data-slot="tooltip-content"]')).toHaveText('Undo: Headline: words') + await undoButton(page).click() + await expect(headline.locator('[data-qb-accent]')).toHaveCount(0) + await expect(headline).not.toHaveText('Help shape what Acme grows into') +}) + +test('the page switcher finds a page by search', async ({ page }) => { + await page.getByRole('button', { name: /^Page:/ }).click() + await page.getByPlaceholder(/Search|Find/i).fill('changel') + // Enter takes the option the search leads with, once the list has caught up. + await expect(page.locator('[cmdk-item][data-selected=true]')).toHaveText('Changelog') + await page.keyboard.press('Enter') + await expect(page.getByRole('button', { name: /^Page:\s*Changelog/ })).toBeVisible() +}) + +/** Where a block sits on Home's grid: its first row and column, as the page lays it out. */ +async function cellOf(frame: FrameLocator, key: string) { + return frame.locator(`[data-qb-section="${key}"]`).evaluate((node) => { + const style = getComputedStyle(node) + return `${style.gridRowStart}/${style.gridColumnStart}` + }) +} + +test('moves a Home block on the grid by keyboard and by mouse, each undone', async ({ page }) => { + const frame = page.frameLocator('iframe[data-designer-canvas]') + const block = frame.locator('[data-qb-section^="block-"]').nth(1) + const key = (await block.getAttribute('data-qb-section'))! + await block.click() + const grip = page.locator('[data-drag-handle]') + await expect(grip).toBeVisible() + const start = await cellOf(frame, key) + + await grip.focus() + await page.keyboard.press('ArrowDown') + await expect.poll(() => cellOf(frame, key)).not.toBe(start) + await expect( + page.getByRole('main', { name: 'Page being edited' }).getByRole('status') + ).toHaveText(/./) + await undoButton(page).click() + await expect.poll(() => cellOf(frame, key)).toBe(start) + + // Dragged by its grip onto the last block's place. + const handle = (await grip.boundingBox())! + const target = (await frame.locator('[data-qb-section^="block-"]').last().boundingBox())! + await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2) + await page.mouse.down() + await page.mouse.move(target.x + 40, target.y + target.height / 2, { steps: 16 }) + await page.mouse.up() + await expect.poll(() => cellOf(frame, key)).not.toBe(start) + await undoButton(page).click() + await expect.poll(() => cellOf(frame, key)).toBe(start) +}) + +test('Theme opens as a sheet over the properties, with a way back to the block', async ({ + page, +}) => { + await pickFromList(page, 'Status') + await page.locator('[data-sheet-button="theme"]').click() + const sheet = page.getByRole('region', { name: 'Theme' }) + await expect(sheet).toBeVisible() + await expect(sheet.getByRole('heading', { name: 'Theme' })).toBeFocused() + await sheet + .getByRole('navigation', { name: 'Where it is' }) + .getByRole('button', { name: 'Status' }) + .click() + await expect(sheet).toBeHidden() + await expect(settings(page).getByRole('heading', { name: 'Status' })).toBeVisible() + // Escape closes a sheet first, then lets go of the block. + await page.locator('[data-sheet-button="theme"]').click() + await expect(page.getByRole('region', { name: 'Theme' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.getByRole('region', { name: 'Theme' })).toBeHidden() + await expect(settings(page)).toHaveAttribute('data-open', '') +}) + +test('Ask AI sits in a pane that sends, collapses, expands and resizes', async ({ page }) => { + const pane = page.locator('#designer-ai-pane') + await expect(pane.getByRole('heading', { name: 'Ask AI' })).toBeVisible() + + // An example fills the box; sending shows the request in the conversation. + // The request to the model is cut off here, so nothing reaches the draft. + const message = 'E2E request that never reaches the model' + await page.route('**/_serverFn/**', async (route) => { + if (route.request().postData()?.includes(message)) return route.abort() + return route.fallback() + }) + await pane.getByRole('button', { name: 'Use our green for buttons' }).click() + const box = pane.getByLabel('Ask Quackback AI') + await expect(box).toHaveValue('Use our green for buttons') + await box.fill(message) + await box.press('Enter') + await expect(pane.getByRole('log', { name: 'Conversation' })).toContainText(message) + + const edge = pane.getByRole('separator', { name: 'Resize Ask AI' }) + await expect(edge).toHaveAttribute('aria-valuenow', '360') + await edge.focus() + await page.keyboard.press('ArrowRight') + await expect(edge).toHaveAttribute('aria-valuenow', '376') + // The pane eases to its new width; the edge is grabbed once it is there. + await expect.poll(async () => (await pane.boundingBox())?.width).toBe(376) + const grip = (await edge.boundingBox())! + await page.mouse.move(grip.x + grip.width / 2, grip.y + 200) + await page.mouse.down() + await page.mouse.move(grip.x + grip.width / 2 + 60, grip.y + 200, { steps: 6 }) + await page.mouse.up() + await expect(edge).toHaveAttribute('aria-valuenow', '436') + + await pane.getByRole('button', { name: 'Collapse Ask AI' }).click() + await expect(pane).toHaveAttribute('data-collapsed', '') + await expect(pane.getByRole('heading', { name: 'Ask AI' })).toBeHidden() + await pane.getByRole('button', { name: 'Ask AI' }).click() + await expect(pane.getByLabel('Ask Quackback AI')).toBeFocused() + // "/" opens it from anywhere outside a field. + await pane.getByRole('button', { name: 'Collapse Ask AI' }).click() + await page.getByRole('heading', { level: 1 }).evaluate((node) => (node as HTMLElement).blur()) + await page.locator('body').press('/') + await expect(pane.getByLabel('Ask Quackback AI')).toBeFocused() +}) + +test('the device switch draws the page at tablet and phone widths', async ({ page }) => { + const canvas = page.locator('iframe[data-designer-canvas]') + await page.getByRole('radio', { name: 'Tablet' }).click() + await expect(page.getByText('Tablet, 768 px wide')).toBeVisible() + await expect(canvas).toHaveCSS('width', '768px') + await page.getByRole('radio', { name: 'Phone' }).click() + await expect(page.getByText('Phone, 390 px wide')).toBeVisible() + await expect(canvas).toHaveCSS('width', '390px') + // A block picked on a phone keeps its chip inside the canvas, with the phone note. + const frame = page.frameLocator('iframe[data-designer-canvas]') + await frame.locator('[data-qb-section^="block-"]').nth(1).click() + await expect(settings(page)).toContainText('On phones, blocks stack in reading order.') + const chip = await page.getByRole('toolbar', { name: /picked/ }).boundingBox() + const frameBox = await canvas.boundingBox() + expect(chip!.x).toBeGreaterThanOrEqual(frameBox!.x) + expect(chip!.x + chip!.width).toBeLessThanOrEqual(frameBox!.x + frameBox!.width * 1.0001) + await page.getByRole('radio', { name: 'Desktop' }).click() + await expect(canvas).toHaveCSS('width', '1280px') +}) + +test('a Suggested card in Add drags onto Home, and undo takes it off', async ({ page }) => { + const frame = page.frameLocator('iframe[data-designer-canvas]') + const blocks = frame.locator('[data-qb-section^="block-"]') + await expect(blocks.first()).toBeVisible() + const before = await blocks.count() + await page.locator('[data-sheet-button="add"]').click() + const suggested = page.getByRole('region', { name: 'Suggested' }) + test.skip((await suggested.count()) === 0, 'Home already shows every part that is on') + // The sheet slides in; the card is grabbed once it has settled. + const first = suggested.getByRole('button').first() + let settled = -1 + await expect + .poll(async () => { + const x = (await first.boundingBox())?.x ?? -2 + const still = x === settled + settled = x + return still + }) + .toBe(true) + const card = (await first.boundingBox())! + const target = (await blocks.first().boundingBox())! + await page.mouse.move(card.x + card.width / 2, card.y + card.height / 2) + await page.mouse.down() + await page.mouse.move(target.x + target.width / 2, target.y + target.height + 8, { steps: 16 }) + await page.mouse.up() + await expect(blocks).toHaveCount(before + 1) + // The added block is picked, so its settings open. + await expect(settings(page)).toHaveAttribute('data-open', '') + await undoButton(page).click() + await expect(blocks).toHaveCount(before) +}) + +test('focusing a field deep in the settings scrolls only the panel, never the page', async ({ + page, +}) => { + await pickFromList(page, 'Hero') + await settings(page).locator('input').last().focus() + const scrolled = await page.evaluate(() => ({ + page: window.scrollY, + panel: document.getElementById('designer-settings')!.scrollTop, + })) + expect(scrolled).toEqual({ page: 0, panel: 0 }) + await expect(settings(page).getByRole('heading', { name: 'Hero' })).toBeInViewport() +}) diff --git a/apps/web/src/components/admin/ask/settings-proposal-cache.ts b/apps/web/src/components/admin/ask/settings-proposal-cache.ts index be634349b7..65783d671f 100644 --- a/apps/web/src/components/admin/ask/settings-proposal-cache.ts +++ b/apps/web/src/components/admin/ask/settings-proposal-cache.ts @@ -6,6 +6,7 @@ const AREA_QUERY_KEYS: Record = { ['settings', 'branding'], ['settings', 'logo'], ['settings', 'headerLogo'], + ['settings', 'portal-design'], ], portal: [['settings', 'publicPortalConfig']], messenger: [ diff --git a/apps/web/src/components/admin/automation/__tests__/copilot-cards.test.tsx b/apps/web/src/components/admin/automation/__tests__/copilot-cards.test.tsx index 4f4e6b304d..a77233ca33 100644 --- a/apps/web/src/components/admin/automation/__tests__/copilot-cards.test.tsx +++ b/apps/web/src/components/admin/automation/__tests__/copilot-cards.test.tsx @@ -232,6 +232,23 @@ describe('Copilot pause control', () => { expect(screen.queryByRole('button', { name: 'Pause Copilot' })).not.toBeInTheDocument() }) + it('says AI is off when both provider keys are set', async () => { + vi.mocked(getAssistantSettingsFn).mockResolvedValue({ + config, + revision: 4, + managedFieldPaths: [], + aiAvailable: false, + aiConfigIssue: 'multiple-providers', + } as never) + renderWithProviders() + await waitFor(() => + expect(screen.getByTestId('line')).toHaveTextContent( + 'AI is off because both OPENAI_API_KEY and ANTHROPIC_API_KEY are set. Keep one.' + ) + ) + expect(screen.queryByRole('button', { name: 'Pause Copilot' })).not.toBeInTheDocument() + }) + it('offers the control when the settings report a configured model', async () => { vi.mocked(getAssistantSettingsFn).mockResolvedValue({ config, diff --git a/apps/web/src/components/admin/automation/copilot-deployment-card.tsx b/apps/web/src/components/admin/automation/copilot-deployment-card.tsx index c43e77d01c..aba4b4d1d9 100644 --- a/apps/web/src/components/admin/automation/copilot-deployment-card.tsx +++ b/apps/web/src/components/admin/automation/copilot-deployment-card.tsx @@ -24,6 +24,13 @@ export function useCopilotStatusLine(availableOverride?: boolean) { const intl = useIntl() const settingsQuery = useQuery(assistantQueries.settings()) const available = availableOverride ?? settingsQuery.data?.aiAvailable !== false + if (settingsQuery.data?.aiConfigIssue === 'multiple-providers') { + return intl.formatMessage({ + id: 'automation.copilot.deployment.multipleProviders', + defaultMessage: + 'AI is off because both OPENAI_API_KEY and ANTHROPIC_API_KEY are set. Keep one.', + }) + } if (!available) { return intl.formatMessage({ id: 'automation.copilot.deployment.unavailable', diff --git a/apps/web/src/components/admin/settings/__tests__/setting-row.test.tsx b/apps/web/src/components/admin/settings/__tests__/setting-row.test.tsx index 69d3b05389..682bf8b5cf 100644 --- a/apps/web/src/components/admin/settings/__tests__/setting-row.test.tsx +++ b/apps/web/src/components/admin/settings/__tests__/setting-row.test.tsx @@ -77,3 +77,12 @@ describe('SettingRow', () => { expect(wrap?.children.length).toBe(2) }) }) + +describe('a setting’s description', () => { + it('is read with its control', () => { + render( {}} />) + const control = screen.getByRole('switch') + const id = control.getAttribute('aria-describedby') + expect(id && document.getElementById(id)?.textContent).toBe('Visitors can comment on posts.') + }) +}) diff --git a/apps/web/src/components/admin/settings/appearance/__tests__/appearance-view.test.tsx b/apps/web/src/components/admin/settings/appearance/__tests__/appearance-view.test.tsx new file mode 100644 index 0000000000..67e32af1ff --- /dev/null +++ b/apps/web/src/components/admin/settings/appearance/__tests__/appearance-view.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment happy-dom +/** + * Appearance is a summary of the portal's design: what visitors see now, what + * waits in the draft, when it was last published, and the way into the + * builder. Nothing on it edits the design. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { defaultDesign } from '@/lib/shared/design/schema' + +const flags = vi.hoisted(() => ({ extra: {} as Record })) + +vi.mock('@tanstack/react-router', () => ({ + useRouter: () => ({ invalidate: async () => {} }), + ClientOnly: ({ children }: { children: ReactNode }) => <>{children}, + useRouteContext: ({ select }: { select: (context: unknown) => unknown }) => + select({ settings: { featureFlags: { feedback: true, ...flags.extra } } }), +})) +vi.mock('@/lib/server/functions/portal-design', () => ({ + getPortalDesignFn: async () => editorState(), + listPortalDesignVersionsFn: async () => versions, + getPortalDesignSwitchFn: async () => ({ canSwitchBack: false, canPublish: true }), + restorePortalDesignVersionFn: async () => ({ ok: true }), + switchBackPortalDesignFn: async () => ({ ok: true }), +})) + +type Version = { + id: `portal_design_revision_${string}` + number: number + source: 'appearance' + message: string | null + principalId: null + createdAt: Date +} +let versions: Version[] = [] + +const { AppearanceView } = await import('../appearance-view') +const { portalDesignQueries } = await import('@/lib/client/queries/portal-design') + +function editorState(changed = false) { + const design = defaultDesign() + if (changed) { + design.brand.light.accent = '#0f766e' + design.layout.density = 'compact' + } + return { + design, + live: defaultDesign(), + source: changed ? ('draft' as const) : ('converted' as const), + published: versions[0] ? { revisionId: versions[0].id, number: versions[0].number } : null, + report: { adjustments: [], errors: [], css: [] }, + revision: 'rev1', + authors: {}, + draftSavedAt: null, + canPublish: true, + } +} + +function mount({ changed = false } = {}) { + const client = new QueryClient() + client.setQueryData(portalDesignQueries.editor().queryKey, editorState(changed)) + client.setQueryData(portalDesignQueries.versions().queryKey, versions) + render( + + + + ) +} + +afterEach(() => { + cleanup() + flags.extra = {} + versions = [] +}) + +describe('AppearanceView', () => { + it('says nothing is published or waiting on a portal still on its first design', () => { + mount() + expect(screen.getByText('Nothing published yet')).toBeTruthy() + expect(screen.getByText('No changes waiting')).toBeTruthy() + }) + + it('shows the live version, when it was published, and the changes waiting', () => { + versions = [ + { + id: 'portal_design_revision_2', + number: 2, + source: 'appearance', + message: null, + principalId: null, + createdAt: new Date(Date.now() - 3 * 86_400_000), + }, + ] + mount({ changed: true }) + expect(screen.getByText(/^Version 2/).textContent).toMatch(/published 3 days ago/) + expect(screen.getByText('2 changes waiting to publish')).toBeTruthy() + }) + + it('edits nothing itself: the builder is where the design changes', () => { + mount({ changed: true }) + expect(screen.queryByRole('tab')).toBeNull() + expect(screen.queryByRole('textbox')).toBeNull() + expect(screen.queryByLabelText(/logo/i)).toBeNull() + expect(screen.getByRole('link', { name: 'Open the builder' }).getAttribute('href')).toBe( + '/design?path=%2F&from=appearance' + ) + }) +}) diff --git a/apps/web/src/components/admin/settings/appearance/__tests__/ask-ai-panel.test.tsx b/apps/web/src/components/admin/settings/appearance/__tests__/ask-ai-panel.test.tsx new file mode 100644 index 0000000000..6c71d28c25 --- /dev/null +++ b/apps/web/src/components/admin/settings/appearance/__tests__/ask-ai-panel.test.tsx @@ -0,0 +1,398 @@ +// @vitest-environment happy-dom +/** + * Ask AI says each step in one line with one Undo for the whole step; the + * changes, as "Thing: old → new", wait behind Show changes. With no model + * set up it says so in one line. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useState } from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { defaultDesign, type PortalDesign } from '@/lib/shared/design/schema' +import { AskAiPanel } from '../ask-ai-panel' +import type { DesignAiTurn } from '../use-design-ai' + +let draft: PortalDesign +/** Each change's undo name, in order. */ +let labels: Array +/** Changes the draft from outside the panel, as the draft bar's Undo does. */ +let setOutside: (design: PortalDesign) => void + +/** The panel over a draft held in state, so it redraws as the draft changes. */ +function Harness(props: Partial[0]>) { + const [design, setDesign] = useState(draft) + setOutside = (next) => { + draft = next + setDesign(next) + } + return ( + {}} + {...props} + design={design} + update={(change, label) => { + const next = structuredClone(draft) + change(next) + labels.push(label) + setOutside(next) + }} + /> + ) +} + +const mount = (props: Partial[0]> = {}) => + render() + +beforeEach(() => { + draft = defaultDesign() + labels = [] +}) +afterEach(cleanup) + +describe('AskAiPanel', () => { + it('is a chat: no start form before the first request', () => { + mount() + expect(screen.queryByLabelText('Describe your brand')).toBeNull() + expect(screen.queryByLabelText(/website|logo or a screenshot/)).toBeNull() + expect(screen.queryByRole('button', { name: /Start/ })).toBeNull() + }) + + it('shows the card for the logo a brand match placed, under its reply', () => { + mount({ + turns: [ + { + role: 'assistant', + text: 'I matched your brand from your screenshot.', + brand: { + shot: {} as never, + box: { x: 0, y: 0, w: 0.1, h: 0.1 }, + full: false, + title: null, + }, + }, + ], + renderBrand: (_turn, index) =>

Logo card {index}

, + }) + expect(screen.getByText('Logo card 0')).toBeTruthy() + }) + + it('says why in one line when no model is set up', () => { + mount({ available: false }) + expect(screen.getByText(/not set up. Everything else works/)).toBeTruthy() + }) + + it('says a step in its reply with one Undo, the changes behind Show changes', () => { + draft.layout.density = 'compact' + draft.brand.light.accent = '#0f766e' + const turns: DesignAiTurn[] = [ + { role: 'user', text: 'Tighter, green' }, + { + role: 'assistant', + text: 'Tighter and greener.', + changes: [ + { path: 'layout.density', before: 'comfortable', after: 'compact', why: 'asked' }, + { path: 'brand.light.accent', before: 'oklch(0.886 0.176 86)', after: '#0f766e' }, + ], + readability: ['button text on the accent'], + }, + ] + mount({ turns }) + expect(screen.getByText('Tighter and greener.', { selector: 'li p' })).toBeTruthy() + // Quiet by default: no list, no card heading, no reasons, no credit. + expect(screen.queryByText('Comfortable')).toBeNull() + expect(screen.queryByText(/Changes applied/)).toBeNull() + expect(screen.queryByText(/Why:/)).toBeNull() + expect(screen.queryByText(/Quackback AI/)).toBeNull() + expect(screen.getAllByRole('button', { name: /^Undo/ })).toHaveLength(1) + + fireEvent.click(screen.getByRole('button', { name: 'Show changes' })) + const rows = screen.getAllByRole('listitem').filter((row) => row.closest('[data-changes]')) + expect(rows.map((row) => row.textContent)).toEqual([ + 'Density:Comfortable→Compact', + 'Brand colour:Yellow→Teal', + ]) + expect(screen.getByText(/Also adjusted button text on the accent/)).toBeTruthy() + expect(screen.queryByText('1.')).toBeNull() + expect(screen.queryByText(/Why:/)).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Undo' })) + expect(draft.layout.density).toBe('comfortable') + expect(draft.brand.light.accent).toBe('oklch(0.886 0.176 86)') + expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull() + expect(screen.getByText('Undone')).toBeTruthy() + }) + + it('leaves out a row whose before and after read the same', () => { + const block = (w: number) => ({ + id: 'statusaaaa', + type: 'status', + grid: { x: 0, y: 0, w, h: 1 }, + }) + draft.pages.home.blocks = [block(12)] as PortalDesign['pages']['home']['blocks'] + draft.layout.density = 'compact' + mount({ + turns: [ + { + role: 'assistant', + text: 'Done.', + changes: [ + { path: 'pages.home.blocks', before: [block(4)], after: [block(12)] }, + { path: 'layout.density', before: 'comfortable', after: 'compact' }, + ], + }, + ], + }) + fireEvent.click(screen.getByRole('button', { name: 'Show changes' })) + expect(screen.queryByText(/1 block/)).toBeNull() + expect(screen.getByText(/Density/)).toBeTruthy() + }) + + it('leaves out exact sizes and lists that would only say Changed', () => { + draft.layout.density = 'compact' + mount({ + turns: [ + { + role: 'assistant', + text: 'Done.', + changes: [ + { path: 'tune.button.radius', before: null, after: 999 }, + { path: 'brand.light.extra', before: null, after: ['#123456'] }, + { path: 'layout.density', before: 'comfortable', after: 'compact' }, + ], + }, + ], + }) + fireEvent.click(screen.getByRole('button', { name: 'Show changes' })) + const rows = screen.getAllByRole('listitem').filter((row) => row.closest('[data-changes]')) + expect(rows.map((row) => row.textContent)).toEqual(['Density:Comfortable→Compact']) + }) + + it('says a brand match in one line of the parts it matched', () => { + draft.brand.light.accent = '#0058a3' + mount({ + turns: [ + { role: 'user', text: 'match my brand' }, + { + role: 'assistant', + text: 'I matched your brand from your screenshot: blue (#0058a3) as the brand colour, Inter for the type.', + matched: true, + changes: [ + { path: 'brand.light.accent', before: '#000000', after: '#0058a3', why: 'logo' }, + { path: 'brand.fonts.heading', before: null, after: { kind: 'bundled', id: 'inter' } }, + ], + }, + ], + }) + expect( + screen.getByText('Matched your brand: colours and fonts.', { selector: 'li p' }) + ).toBeTruthy() + expect(screen.queryByText(/Inter for the type/)).toBeNull() + }) + + it('undoes an override step by its one Undo, its lines in plain words', () => { + const after = [ + { + id: 'part:hero.home.cta:button', + target: { scope: 'part', part: 'hero.home.cta', element: 'button' }, + style: { base: { background: 'black' } }, + }, + ] as NonNullable + draft.overrides = structuredClone(after) + mount({ + turns: [ + { role: 'user', text: 'Just this one black' }, + { + role: 'assistant', + text: 'Made the call to action black.', + changes: [ + { + path: 'overrides', + before: null, + after: after as never, + label: 'Call to action · Hero: background to black', + }, + ], + }, + ], + }) + fireEvent.click(screen.getByRole('button', { name: 'Show changes' })) + expect(screen.getByText('Call to action background: Brand → Black (this one)')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Undo' })) + expect(draft.overrides ?? []).toEqual([]) + }) + + it('undoes only what still holds when the draft moved on since', () => { + draft.layout.density = 'compact' + draft.brand.light.accent = '#0f766e' + mount({ + turns: [ + { + role: 'assistant', + text: 'Tighter and greener.', + changes: [ + { path: 'layout.density', before: 'comfortable', after: 'compact' }, + { path: 'brand.light.accent', before: '#123456', after: '#0f766e' }, + ], + }, + ], + }) + // A later change of the person's own moves the colour on; Undo leaves it. + const later = structuredClone(draft) + later.brand.light.accent = '#ff0000' + act(() => setOutside(later)) + expect(screen.queryByText('Changed since')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Undo' })) + expect(draft.layout.density).toBe('comfortable') + expect(draft.brand.light.accent).toBe('#ff0000') + // Nothing of the step holds now, so its Undo goes, quietly. + expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull() + expect(screen.queryByText('No longer in your draft')).toBeNull() + }) + + it('announces an answer as its one line through a status that is always there', () => { + draft.layout.density = 'compact' + const { rerender } = mount({ busy: true, stage: 'Reading example.com…' }) + const status = screen.getByRole('status') + expect(status.textContent).toBe('Reading example.com…') + expect(document.querySelector('[aria-busy]')).toBeNull() + rerender( + + ) + expect(screen.getByRole('status')).toBe(status) + expect(status.textContent).toBe('Tighter.') + // The list itself is not live, so a person's own words and each row are not read again. + expect(document.querySelector('ol')?.hasAttribute('aria-live')).toBe(false) + }) + + it('reads a switch as On and Off', () => { + mount({ + turns: [ + { + role: 'assistant', + text: 'The welcome message shows.', + changes: [{ path: 'pages.feedback.welcome.show', before: false, after: true }], + }, + ], + }) + fireEvent.click(screen.getByRole('button', { name: 'Show changes' })) + expect(screen.getByText('Off')).toBeTruthy() + expect(screen.getByText('On')).toBeTruthy() + expect(screen.queryByText('false')).toBeNull() + }) + + it('offers to ask about the whole site when a request reached outside the section', () => { + const widened: string[] = [] + mount({ + turns: [ + { role: 'user', text: 'A green band' }, + { + role: 'assistant', + text: 'That is outside the header, so nothing changed.', + widen: 'A green band', + }, + ], + onWiden: (text) => widened.push(text), + }) + fireEvent.click(screen.getByRole('button', { name: 'Ask about the whole site' })) + expect(widened).toEqual(['A green band']) + }) + + it('shows a brand match’s stages as they run, the last one in progress', () => { + mount({ + busy: true, + stage: 'Matching fonts…', + steps: ['Reading your screenshots', 'Matching fonts'], + }) + const progress = screen.getByRole('list', { name: 'Progress' }) + expect(progress.textContent).toContain('✓Reading your screenshots') + expect(progress.textContent).toContain('Matching fonts…') + }) + + it('asks a match’s questions quietly and offers follow-ups, its notes with the changes', () => { + const onChip = vi.fn() + const turns: DesignAiTurn[] = [ + { role: 'user', text: 'match my brand' }, + { + role: 'assistant', + text: 'I matched your brand.', + matched: true, + changes: [ + { + path: 'brand.light.accent', + before: '#000000', + after: '#0058a3', + why: 'seen in the logo', + }, + ], + asks: ['Add a screenshot of a product or account page and I’ll match them exactly.'], + notes: ['Not visible in screenshots, so left as is: hover.'], + chips: ['Use more blue', 'Bolder hero'], + }, + ] + draft = { + ...draft, + brand: { ...draft.brand, light: { ...draft.brand.light, accent: '#0058a3' } }, + } + mount({ turns, onChip }) + expect(screen.getByText(/Add a screenshot of a product/)).toBeTruthy() + expect(screen.queryByText(/Not visible in screenshots/)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Show changes' })) + expect(screen.getByText(/Not visible in screenshots/)).toBeTruthy() + expect(screen.queryByText(/seen in the logo/)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Use more blue' })) + expect(onChip).toHaveBeenCalledWith('Use more blue') + }) +}) + +describe('the capture a match was made from', () => { + const capture = { + host: 'acme.test', + url: 'acme.test', + image: 'data:image/jpeg;base64,AAAA', + cached: true, + capturedAt: '2026-11-03T08:00:00.000Z', + againFrom: '2026-11-04T08:00:00.000Z', + } + + it('shows the page, where it came from, and Capture again once a day has passed', async () => { + const { SiteCapture } = await import('../ask-ai-panel') + const onRecapture = vi.fn() + const { rerender } = render( + + ) + expect(screen.getByRole('img', { name: 'Captured from acme.test' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Capture again' }).hasAttribute('disabled')).toBe( + true + ) + rerender( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Capture again' })) + expect(onRecapture).toHaveBeenCalledWith('acme.test') + }) +}) diff --git a/apps/web/src/components/admin/settings/appearance/__tests__/change-list.test.tsx b/apps/web/src/components/admin/settings/appearance/__tests__/change-list.test.tsx new file mode 100644 index 0000000000..ecd7c4a5e0 --- /dev/null +++ b/apps/web/src/components/admin/settings/appearance/__tests__/change-list.test.tsx @@ -0,0 +1,222 @@ +// @vitest-environment happy-dom +/** + * Review and publish names each change the way the Appearance controls do. + */ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { ChangeList, changeLabel, shownValue } from '../change-list' + +describe('changeLabel', () => { + it.each([ + ['brand.light.accent', 'Brand colour'], + ['brand.light.support', 'Second colour'], + ['brand.light.extra', 'Extra colours'], + ['brand.light.extra.1', 'Extra colours'], + ['layout.bandColour', 'Hero colour'], + ['tune.subtitle.size', 'Subtitle size'], + ['brand.dark.ink', 'Dark text'], + ['brand.fonts.heading.id', 'Heading font'], + ['layout.nav', 'Menu style'], + ['pages.feedback.defaultSort', 'Default sort'], + ['pages.feedback.headline.title.en', 'Headline'], + ['pages.feedback.headline', 'Headline'], + ['pages.feedback.headline.subtitle.de', 'Subtitle'], + ['pages.feedback.welcome.show', 'Welcome message'], + ['pages.feedback.welcome.body.content', 'Welcome message text'], + ['brand.logo.mark.light.key', 'Icon'], + ['brand.logo.mark.dark.key', 'Icon on dark'], + ['brand.logo.wordmark.light.key', 'Full logo'], + ['brand.logo.display', 'Full logo image'], + ['brand.favicon.key', 'Favicon'], + ['brand.shareImage.key', 'Share image'], + ['nav.home.on', 'Home page'], + ['nav.home.tab', 'Home in the menu'], + ['pages.home.headline.title.en', 'Home headline'], + ['pages.home.headline', 'Home headline'], + ['pages.home.headline.subtitle.fr', 'Home subtitle'], + ['pages.home.blocks', 'Home blocks'], + ['nav.items', 'Menu'], + ['nav.home.label', 'Home tab name'], + ['pages.feedback.copy.prompt.en', 'Composer prompt'], + ['pages.feedback.copy.submit.de', 'Submit button'], + ['pages.feedback.copy.emptyTitle', 'Empty feed title'], + ['tune.headline.size', 'Headline size'], + ['tune.button.ink', 'Button label colour'], + ['tune', 'Exact values'], + ])('%s is %s', (path, label) => { + expect(changeLabel(path)).toBe(label) + }) +}) + +describe('ChangeList', () => { + it('never says who made a change', () => { + const { container } = render( + + ) + const rows = container.querySelectorAll('li') + expect(rows).toHaveLength(2) + for (const row of rows) expect(row.textContent).not.toMatch(/Quackback AI|by you/i) + }) + + it('says the composer’s width in the hero in words', () => { + const { container } = render( + + ) + const row = container.querySelector('li')! + expect(row.textContent).toContain('Home composer width') + expect(row.textContent).toContain('Narrow') + expect(row.textContent).not.toContain('narrow') + }) + + it('names an uploaded image, never its storage key', () => { + const { container } = render( + + ) + expect(container.textContent).toContain('Uploaded image') + expect(container.textContent).not.toContain('logos/') + }) + + it('counts Home’s blocks rather than calling them changed', () => { + const block = { type: 'status', width: 'third' } + const { container } = render( + + ) + expect(container.textContent).toContain('No blocks') + expect(container.textContent).toContain('2 blocks') + expect(container.textContent).not.toContain('Changed') + }) + + it('names a font by its family when it becomes the workspace’s own', () => { + const { container } = render( + + ) + expect(container.querySelectorAll('li')).toHaveLength(1) + expect(container.querySelector('li')!.textContent).toBe('Heading fontFraunces→Unbounded') + }) + + it('counts the custom CSS in lines rather than showing it', () => { + const { container } = render( + + ) + expect(container.textContent).toContain('Custom CSS') + expect(container.textContent).toContain('None') + expect(container.textContent).toContain('2 lines') + expect(container.textContent).not.toContain('a { b: c }') + }) + + it('counts what the menu shows', () => { + const { container } = render( + + ) + expect(container.textContent).toContain('2 shown') + expect(container.textContent).not.toContain('Changed') + }) + + it('calls an empty headline none, while a colour left unset stays automatic', () => { + const { container } = render( + + ) + const rows = [...container.querySelectorAll('li')] + const headline = rows.find((row) => row.textContent?.includes('Home headline')) + const colour = rows.find((row) => row.textContent?.includes('Raised surfaces')) + expect(headline!.textContent).toContain('None') + expect(colour!.textContent).toContain('Automatic') + }) +}) + +describe('shownValue', () => { + it('gives exact values their unit, and words in another language that language', () => { + expect(shownValue('tune.headline.size', 48)).toBe('48 px') + expect(shownValue('tune.headline.tracking', -0.02)).toBe('-0.02 em') + expect(shownValue('tune.headline.weight', 600)).toBe(600) + expect(shownValue('pages.home.blocks.0.heading', { de: 'Neues' })).toBe('Neues (German)') + }) +}) + +describe('inLanguage', () => { + it('names a word field’s language when it is not English', async () => { + const { inLanguage } = await import('@/lib/shared/design/labels') + expect(inLanguage('Headline', 'en')).toBe('Headline') + expect(inLanguage('Headline', 'de')).toBe('Headline (German)') + }) +}) + +describe('alsoChanges', () => { + it('says what else a shared field reaches, and nothing for one of its own', async () => { + const { alsoChanges } = await import('@/lib/shared/design/labels') + expect(alsoChanges('shape.corners')).toMatch(/cards, fields and buttons/) + expect(alsoChanges('tune.label.size')).toMatch(/menu/) + expect(alsoChanges('tune.button.radius')).toBeNull() + }) +}) + +describe('ChangeList groups', () => { + it('groups changes by the part of the portal they are in, in the order the parts show', () => { + render( + + ) + expect(screen.getAllByRole('heading').map((heading) => heading.textContent)).toEqual([ + 'Site styles', + 'Hero', + 'Home', + ]) + }) +}) + +describe('page backgrounds in the change list', () => { + it('names a background by its kind, and the pages with their own', () => { + expect(shownValue('layout.background', { kind: 'gradient', stops: ['accent', 'page'] })).toBe( + 'Gradient' + ) + expect(shownValue('layout.background', { kind: 'tint', colour: '#f7f5f2' })).toBe('Soft colour') + expect(shownValue('layout.background', null)).toBe('Page colour') + expect( + shownValue('layout.pageBackgrounds', { + roadmap: { kind: 'pattern', pattern: 'dots' }, + help: { kind: 'solid' }, + }) + ).toBe('Roadmap: Pattern, Help Center: Page colour') + }) +}) diff --git a/apps/web/src/components/admin/settings/appearance/__tests__/css-section.test.tsx b/apps/web/src/components/admin/settings/appearance/__tests__/css-section.test.tsx new file mode 100644 index 0000000000..de8c865f5d --- /dev/null +++ b/apps/web/src/components/admin/settings/appearance/__tests__/css-section.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom +/** + * Custom CSS: the editor writes the draft's CSS, emptying it + * removes it, and "Fix it for me" swaps each internal class for its part, + * only where the class name is whole. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { defaultDesign, type PortalDesign } from '@/lib/shared/design/schema' + +vi.mock('@/components/admin/settings/branding/custom-css-editor', () => ({ + CustomCssEditor: ({ value, onChange }: { value: string; onChange: (css: string) => void }) => ( + ' + document.body.append(settings) + Element.prototype.scrollIntoView = vi.fn() + revealSetting('Custom CSS') + await waitFor(() => expect(settings.querySelector('details')!.open).toBe(true)) + settings.remove() + }) +}) diff --git a/apps/web/src/components/designer/__tests__/regions.test.tsx b/apps/web/src/components/designer/__tests__/regions.test.tsx new file mode 100644 index 0000000000..0b6ff9b249 --- /dev/null +++ b/apps/web/src/components/designer/__tests__/regions.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment happy-dom +/** F6 and Shift+F6 move between the builder's regions, in the order they show. */ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cycleRegions } from '../regions' + +beforeAll(() => window.addEventListener('keydown', cycleRegions)) +afterAll(() => window.removeEventListener('keydown', cycleRegions)) +afterEach(cleanup) + +function Builder({ flyout = true }) { + return ( +
+
+ +
+
+ + +
+ {flyout &&
} +
+ ) +} + +const f6 = (shift = false) => + fireEvent.keyDown(document.activeElement ?? document.body, { key: 'F6', shiftKey: shift }) + +describe('cycleRegions', () => { + it('goes to the next region, and from the last back to the first', () => { + render() + screen.getByRole('button', { name: 'Publish' }).focus() + f6() + // The rail takes focus on its button that takes Tab, not on itself. + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Styles' })) + f6() + expect(document.activeElement?.getAttribute('aria-label')).toBe('Styles') + f6() + expect(document.activeElement?.getAttribute('aria-label')).toBe('Settings') + f6() + expect(document.activeElement?.getAttribute('aria-label')).toBe('Top bar') + }) + + it('starts at the top bar from the page’s body', () => { + render() + ;(document.activeElement as HTMLElement | null)?.blur() + fireEvent.keyDown(document.body, { key: 'F6' }) + expect(document.activeElement?.getAttribute('aria-label')).toBe('Top bar') + }) + + it('goes back with Shift, and skips a panel that is closed', () => { + render() + screen.getByRole('button', { name: 'Publish' }).focus() + f6(true) + expect(document.activeElement?.getAttribute('aria-label')).toBe('Settings') + f6(true) + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Styles' })) + }) +}) diff --git a/apps/web/src/components/designer/__tests__/review-compare.test.tsx b/apps/web/src/components/designer/__tests__/review-compare.test.tsx new file mode 100644 index 0000000000..a5b55dbfcd --- /dev/null +++ b/apps/web/src/components/designer/__tests__/review-compare.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom +// @vitest-environment-options { "settings": { "disableIframePageLoading": true, "handleDisabledFileLoadingAsSuccess": true } } +/** + * Review shows any page before and after, opening on the one being designed, + * a post at its own address. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' + +vi.mock('@tanstack/react-router', () => ({ + ClientOnly: ({ children }: { children: React.ReactNode }) => children, +})) + +const { ReviewCompare } = await import('../review-compare') + +afterEach(cleanup) + +const design = {} as never + +describe('ReviewCompare', () => { + it('opens on the page being designed, and shows another when asked', () => { + render( + + ) + const paths = () => + [...document.querySelectorAll('iframe')].map( + (frame) => new URL(frame.src, 'http://x').pathname + ) + expect(paths()).toEqual(['/feedback', '/feedback']) + fireEvent.click(screen.getByRole('button', { name: 'A post' })) + expect(paths()).toEqual(['/b/ideas/posts/p1', '/b/ideas/posts/p1']) + expect(screen.getByRole('button', { name: 'A post' }).getAttribute('aria-pressed')).toBe('true') + }) +}) diff --git a/apps/web/src/components/designer/__tests__/set-differently.test.ts b/apps/web/src/components/designer/__tests__/set-differently.test.ts new file mode 100644 index 0000000000..0877940503 --- /dev/null +++ b/apps/web/src/components/designer/__tests__/set-differently.test.ts @@ -0,0 +1,97 @@ +/** + * The one list of places an element's look is set differently: page and + * part overrides and Home blocks' own looks, each saying what it sets with + * its value, and which values are phones' or dark mode's own. Theme lists + * them all; an element's panel lists its own, from the same words. + */ +import { describe, expect, it } from 'vitest' +import { defaultDesign, portalDesign, type PortalDesign } from '@/lib/shared/design/schema' +import { alsoSetAt } from '@/lib/shared/design/override-cascade' +import { lookDetail, placeDetail, setDifferently } from '../set-differently' + +function design(): PortalDesign { + return portalDesign.parse({ + ...defaultDesign(), + pages: { + ...defaultDesign().pages, + home: { + headline: null, + blocks: [ + { + type: 'ideas', + id: 'abcdefgh', + sort: 'top', + count: 3, + grid: { x: 0, y: 0, w: 6, h: 2 }, + look: { + style: { link: { base: { color: 'ink' } } }, + phone: { link: { base: { size: 13 } } }, + }, + }, + ], + }, + }, + overrides: [ + { + id: 'o1', + target: { scope: 'page', page: 'changelog', element: 'button' }, + style: { base: { radius: 4 } }, + }, + { + id: 'o2', + target: { scope: 'part', part: 'hero.home.cta', element: 'button' }, + style: { hover: { background: 'support' } }, + styleDark: { base: { background: 'black' } }, + phone: { base: { size: 18 } }, + }, + { + id: 'o3', + target: { scope: 'part', part: 'block.gone1234.link', element: 'link' }, + style: { base: { weight: 700 } }, + }, + ], + }) +} + +describe('what a place sets differently', () => { + it('says each value, and which are a state’s, phones’ or dark mode’s', () => { + expect( + lookDetail('button', { + style: { base: { radius: 4 }, hover: { background: 'support' } }, + phone: { base: { size: 18 } }, + dark: { base: { background: 'black' } }, + }) + ).toBe( + 'corners 4 px, background Second colour (hover), size 18 px (phone), background Black (dark)' + ) + }) +}) + +describe('every place set differently', () => { + it('lists overrides and Home blocks’ own looks alike, and flags a part that is gone', () => { + const list = setDifferently(design()) + expect(list.map((p) => [p.group, p.name, p.detail, p.orphan])).toEqual([ + ['Changelog', 'Main buttons on Changelog', 'corners 4 px', false], + [ + 'Hero · Home', + 'Call to action · Hero', + 'background Second colour (hover), size 18 px (phone), background Black (dark)', + false, + ], + ['Gone from the portal', 'Links in a block that is gone', 'weight 700', true], + ['Top posts', 'Links in Top posts', 'text Text, size 13 px (phone)', false], + ]) + }) + + it('agrees with an element panel’s own list, word for word', () => { + const d = design() + const ctx = { element: 'button' as const, part: null, page: 'home' as const, block: null } + const panel = alsoSetAt(d, ctx, 'everywhere').map( + (place) => `${place.label}: ${placeDetail(d, 'button', place)}` + ) + const theme = setDifferently(d) + .filter((p) => p.element === 'button') + .map((p) => `${p.place.label}: ${p.detail}`) + expect(panel.sort()).toEqual(theme.sort()) + }) +}) diff --git a/apps/web/src/components/designer/__tests__/share-preview.test.tsx b/apps/web/src/components/designer/__tests__/share-preview.test.tsx new file mode 100644 index 0000000000..af4ab8caea --- /dev/null +++ b/apps/web/src/components/designer/__tests__/share-preview.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment happy-dom +/** + * Share preview makes a link to the draft as it is, saving it first, copies + * the link, and lists the live links with a way to revoke each. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +const fns = vi.hoisted(() => ({ + list: vi.fn(), + create: vi.fn(), + revoke: vi.fn(), +})) +vi.mock('@/lib/server/functions/portal-design-preview', () => ({ + listPortalDesignPreviewLinksFn: fns.list, + createPortalDesignPreviewLinkFn: fns.create, + revokePortalDesignPreviewLinkFn: fns.revoke, +})) +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +const events = vi.hoisted(() => ({ trackBuilder: vi.fn() })) +vi.mock('@/lib/client/builder-events', () => events) + +const { SharePreviewDialog } = await import('../share-preview') + +const URL_MADE = `https://portal.example.com/?share=qbp_${'a'.repeat(43)}` +const link = (id: string, createdBy: string | null = 'Ada') => ({ + id, + createdAt: '2026-11-01T10:00:00.000Z', + expiresAt: '2026-11-08T10:00:00.000Z', + createdBy, +}) + +const writeText = vi.fn(async () => undefined) +beforeEach(() => { + fns.list.mockReset().mockResolvedValue([link('portal_design_preview_old')]) + fns.create + .mockReset() + .mockResolvedValue({ ok: true, url: URL_MADE, link: link('portal_design_preview_new') }) + fns.revoke.mockReset().mockResolvedValue({ ok: true }) + writeText.mockClear() + events.trackBuilder.mockClear() + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) +}) +afterEach(cleanup) + +function open(flush: () => Promise = vi.fn(async () => {})) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + {}} flush={flush} /> + + ) + return flush +} + +describe('SharePreviewDialog', () => { + it('saves the draft, makes a link and copies it', async () => { + const order: string[] = [] + const flush = vi.fn(async () => { + order.push('flush') + }) + fns.create.mockImplementation(async () => { + order.push('create') + return { ok: true, url: URL_MADE, link: link('portal_design_preview_new') } + }) + open(flush) + fireEvent.click(await screen.findByRole('button', { name: 'Create link' })) + expect(await screen.findByDisplayValue(URL_MADE)).toBeTruthy() + expect(order).toEqual(['flush', 'create']) + expect(writeText).toHaveBeenCalledWith(URL_MADE) + expect(events.trackBuilder.mock.calls).toEqual([['designer_preview_link_shared', {}]]) + }) + + it('says why a link could not be made', async () => { + fns.create.mockResolvedValue({ + ok: false, + code: 'PREVIEW_LINK_LIMIT', + message: 'A portal can have 20 preview links at once. Revoke one to share another.', + }) + open() + fireEvent.click(await screen.findByRole('button', { name: 'Create link' })) + expect(await screen.findByText(/20 preview links at once/)).toBeTruthy() + expect(writeText).not.toHaveBeenCalled() + expect(events.trackBuilder).not.toHaveBeenCalled() + }) + + it('lists the live links and revokes one', async () => { + open() + expect(await screen.findByText(/by Ada/)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Revoke' })) + await waitFor(() => + expect(fns.revoke).toHaveBeenCalledWith({ data: { id: 'portal_design_preview_old' } }) + ) + await waitFor(() => expect(fns.list).toHaveBeenCalledTimes(2)) + }) +}) diff --git a/apps/web/src/components/designer/__tests__/styles-panel.test.tsx b/apps/web/src/components/designer/__tests__/styles-panel.test.tsx new file mode 100644 index 0000000000..0426c855d4 --- /dev/null +++ b/apps/web/src/components/designer/__tests__/styles-panel.test.tsx @@ -0,0 +1,175 @@ +// @vitest-environment happy-dom +/** + * Styles, under Theme's More options: how the portal uses the brand kit. + * Opening it never changes the design. It lists only the parts the modules + * that are on draw, and its Advanced, which holds custom CSS, is counted when + * opened. (Corners are in Theme itself; the hero's look is in its own panel.) + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useState } from 'react' +import { defaultDesign, type PortalDesign } from '@/lib/shared/design/schema' +import type { PortalPageGates } from '@/lib/shared/design/paths' + +const events = vi.hoisted(() => ({ + designerAdvancedOpened: vi.fn(), + designerLookChosen: vi.fn(), +})) +vi.mock('@/lib/client/builder-events', () => events) +vi.mock('@/lib/server/functions/portal-design-saved', () => ({ + listPortalDesignSavedFn: async () => ({ checkpoints: [], looks: [] }), + savePortalDesignFn: vi.fn(), + getPortalDesignCheckpointFn: vi.fn(), + deletePortalDesignSavedFn: vi.fn(), +})) +vi.mock('@/components/admin/settings/branding/custom-css-editor', () => ({ + CustomCssEditor: ({ value, onChange }: { value: string; onChange: (css: string) => void }) => ( +