* Start scanner-mobile-checkout convoy for the cart-then-commit phone flow. Co-authored-by: Cursor <cursoragent@cursor.com> * Ship a cart-then-commit mobile scanner so phone sessions stay on the camera. Scan matches enqueue locally instead of auto-writing ownership, checkout happens in a sheet, and audit fixes cover stale commit detection, returnUrl open redirects, nested Escape, and ember detection chrome. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
217 lines
8.4 KiB
JavaScript
217 lines
8.4 KiB
JavaScript
// @vitest-environment jsdom
|
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import { render, cleanup, screen } from '@testing-library/react';
|
|
|
|
// Mock next/router so useRouter() does not crash without a RouterContext.
|
|
// Layout reads `router.pathname` only; the rest of the surface (`prefetch`,
|
|
// `events`, `push`) is for next/link's internals — provide stubs so prefetch
|
|
// does not throw when <Link> mounts.
|
|
vi.mock('next/router', () => ({
|
|
useRouter: () => ({
|
|
pathname: '/',
|
|
asPath: '/',
|
|
query: {},
|
|
push: vi.fn(),
|
|
replace: vi.fn(),
|
|
prefetch: vi.fn().mockResolvedValue(undefined),
|
|
events: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
|
|
}),
|
|
}));
|
|
|
|
// Mock next/link to a plain <a>. The real next/link triggers prefetch on
|
|
// mount via the router; bypassing it removes a class of jsdom flake without
|
|
// changing the rendered DOM that the assertions inspect.
|
|
vi.mock('next/link', () => ({
|
|
__esModule: true,
|
|
default: ({ href, children, ...rest }) => {
|
|
return (
|
|
<a href={typeof href === 'string' ? href : ''} {...rest}>
|
|
{children}
|
|
</a>
|
|
);
|
|
},
|
|
}));
|
|
|
|
// Mock the theme context so useTheme() does not require a ThemeProvider.
|
|
vi.mock('../../lib/theme-context', () => ({
|
|
useTheme: () => ({ theme: 'light', toggleTheme: vi.fn() }),
|
|
}));
|
|
|
|
import Layout from '../../components/Layout';
|
|
|
|
describe('Layout — logged-out rendering (regression: P0 #7)', () => {
|
|
afterEach(() => cleanup());
|
|
|
|
it('does NOT render the maintainer email when no user prop is passed', () => {
|
|
const { container } = render(<Layout>page body</Layout>);
|
|
expect(container.textContent).not.toContain('me@randallstillwell.com');
|
|
});
|
|
|
|
it('does NOT render the maintainer email when user is null', () => {
|
|
const { container } = render(<Layout user={null}>page body</Layout>);
|
|
expect(container.textContent).not.toContain('me@randallstillwell.com');
|
|
});
|
|
|
|
it('renders a Sign-in link to /login when user is null', () => {
|
|
render(<Layout user={null}>page body</Layout>);
|
|
const links = screen.getAllByRole('link', { name: /sign in/i });
|
|
expect(links.length).toBeGreaterThanOrEqual(1);
|
|
// Both desktop sidebar + mobile drawer render UserProfileDropdown,
|
|
// so we expect TWO Sign-in links (one per copy).
|
|
for (const link of links) {
|
|
expect(link.getAttribute('href')).toBe('/login');
|
|
}
|
|
});
|
|
|
|
it('flows the supplied user through to the rendered surface (TopSearchBar chip)', () => {
|
|
// Pre-refinements (2026-06-04), the UserProfileDropdown in the
|
|
// sidebar displayed the full email. The redesign-v2 refinements
|
|
// moved the user-menu to the TopSearchBar's compact chip in the
|
|
// top-right, which displays the username (or the email's local
|
|
// part as a fallback) rather than the full address. The
|
|
// maintainer-email regression-lock from P0 #7 still passes via
|
|
// the three "does NOT render me@randallstillwell.com" cases
|
|
// above; this case continues to assert that the user prop FLOWS
|
|
// through, just against the new render surface.
|
|
const { container } = render(
|
|
<Layout user={{ email: 'foo@bar.com', role: 'user' }}>page body</Layout>
|
|
);
|
|
expect(container.textContent).toContain('foo');
|
|
expect(container.textContent).not.toContain('me@randallstillwell.com');
|
|
});
|
|
|
|
it('does NOT render a "Guest" placeholder when logged out', () => {
|
|
// Decision A says the logged-out copy is "Sign in", not "Guest".
|
|
// This test prevents a future revert that ships "Guest" as the default
|
|
// (which would still hide the maintainer email but skip the CTA).
|
|
const { container } = render(<Layout user={null}>page body</Layout>);
|
|
expect(container.textContent).not.toContain('Guest');
|
|
});
|
|
});
|
|
|
|
describe('Layout — floating popovers use .glass-panel-strong (regression: Brief 3 of unify-glass-panel-surfaces)', () => {
|
|
afterEach(() => cleanup());
|
|
|
|
// The sidebar UserProfileDropdown and the mobile drawer are floating
|
|
// glass surfaces. Brief 3 migrated them from inline
|
|
// `background: var(--glass-surface-*)` + `backdropFilter` to the
|
|
// canonical `.glass-panel-strong` className. If a future PR reverts
|
|
// either to the inline pattern, the bespoke-glass-surface CI gate
|
|
// (Brief 7) will catch the regression — these unit tests catch the
|
|
// SAME regression at the component-render level, before the gate
|
|
// would fire.
|
|
|
|
it('mobile drawer panel carries .glass-panel-strong when isMobileMenuOpen', () => {
|
|
const { container } = render(
|
|
<Layout user={{ email: 'foo@bar.com', role: 'user' }}>page body</Layout>
|
|
);
|
|
// The drawer is rendered (translated off-screen via `-translate-x-full`)
|
|
// even when closed, so we can assert on it from a default mount.
|
|
const drawer = container.querySelector(
|
|
'.glass-panel-strong.md\\:hidden.fixed.inset-y-0.left-0'
|
|
);
|
|
expect(drawer).not.toBeNull();
|
|
expect(drawer.className).toContain('glass-panel-strong');
|
|
expect(drawer.className).toContain('w-64');
|
|
});
|
|
|
|
it('mobile drawer panel does NOT carry an inline `background: var(--glass-surface-*)` style after Brief 3', () => {
|
|
// Regression lock against reverting Brief 3 to handrolled inline
|
|
// glass styles on the mobile drawer specifically. The drawer is
|
|
// always in the DOM (translated off-screen when closed), so we can
|
|
// query it from a default mount.
|
|
const { container } = render(
|
|
<Layout user={{ email: 'foo@bar.com', role: 'user' }}>page body</Layout>
|
|
);
|
|
const drawer = container.querySelector(
|
|
'.glass-panel-strong.md\\:hidden.fixed.inset-y-0.left-0'
|
|
);
|
|
expect(drawer).not.toBeNull();
|
|
const inlineStyle = drawer.getAttribute('style') ?? '';
|
|
expect(inlineStyle).not.toMatch(/var\(--glass-surface-/);
|
|
expect(inlineStyle).not.toMatch(/backdrop-filter/i);
|
|
expect(inlineStyle).toContain('box-shadow');
|
|
expect(inlineStyle).toContain('--elevation-pronounced');
|
|
});
|
|
});
|
|
|
|
describe('Layout — immersive chrome (regression: scanner-mobile-checkout Brief 1)', () => {
|
|
afterEach(() => cleanup());
|
|
|
|
const testUser = { email: 'foo@bar.com', role: 'user' };
|
|
|
|
const mobileBottomNavSelector =
|
|
'.md\\:hidden.fixed.bottom-0.left-0.right-0.z-50';
|
|
|
|
it('hides the mobile bottom navigation when chrome is immersive', () => {
|
|
const { container } = render(
|
|
<Layout user={testUser} chrome="immersive">
|
|
page body
|
|
</Layout>
|
|
);
|
|
expect(container.querySelector(mobileBottomNavSelector)).toBeNull();
|
|
});
|
|
|
|
it('renders the mobile bottom navigation when chrome is default', () => {
|
|
const { container } = render(
|
|
<Layout user={testUser} chrome="default">
|
|
page body
|
|
</Layout>
|
|
);
|
|
expect(container.querySelector(mobileBottomNavSelector)).not.toBeNull();
|
|
});
|
|
|
|
it('keeps TopSearchBar in the DOM with a mobile-only hide wrapper when immersive', () => {
|
|
const { container } = render(
|
|
<Layout user={testUser} chrome="immersive">
|
|
page body
|
|
</Layout>
|
|
);
|
|
expect(
|
|
screen.getByLabelText('Open command palette to search')
|
|
).toBeTruthy();
|
|
const topBarWrapper = container.querySelector('.max-md\\:hidden');
|
|
expect(topBarWrapper).not.toBeNull();
|
|
expect(
|
|
topBarWrapper.querySelector('[aria-label="Open command palette to search"]')
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it('does not wrap TopSearchBar in max-md:hidden when chrome is default', () => {
|
|
const { container } = render(
|
|
<Layout user={testUser} chrome="default">
|
|
page body
|
|
</Layout>
|
|
);
|
|
expect(container.querySelector('.max-md\\:hidden')).toBeNull();
|
|
expect(
|
|
screen.getByLabelText('Open command palette to search')
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('applies max-md:pb-0 and max-md:p-0 shell classes when immersive', () => {
|
|
const { container } = render(
|
|
<Layout user={testUser} chrome="immersive">
|
|
page body
|
|
</Layout>
|
|
);
|
|
const outerShell = container.firstChild;
|
|
expect(outerShell.className).toContain('max-md:p-0');
|
|
|
|
const mainColumn = container.querySelector('.flex-1.flex.flex-col.min-w-0');
|
|
expect(mainColumn.className).toContain('max-md:pb-0');
|
|
});
|
|
|
|
it('still renders Sign-in CTA when user is null and chrome is immersive', () => {
|
|
render(
|
|
<Layout user={null} chrome="immersive">
|
|
page body
|
|
</Layout>
|
|
);
|
|
const links = screen.getAllByRole('link', { name: /sign in/i });
|
|
expect(links.length).toBeGreaterThanOrEqual(1);
|
|
for (const link of links) {
|
|
expect(link.getAttribute('href')).toBe('/login');
|
|
}
|
|
});
|
|
});
|