From 1c6f6093a781a3c8ada00c0ca20c028e51538357 Mon Sep 17 00:00:00 2001 From: Salah Al-Nawah Date: Mon, 26 Oct 2026 21:03:59 +0000 Subject: [PATCH 1/3] test(SAL-3525): cover router, route files and entries; raise coverage floor Tests the router registry wiring, every route file (loaders, heads, skeletons, search validation), the client/server/start entries and the root shell. Floor raised to 88/75/83/85 (lines/branches/functions/statements). routeTree.gen is aliased to a stub under vitest since it is generated at build time. Made-with: agent Co-authored-by: mc-agents[bot] <298817976+mc-agents[bot]@users.noreply.github.com> --- tests/routes/entry.test.tsx | 82 ++++++ tests/routes/root.test.tsx | 49 +++- tests/routes/routeFiles.test.tsx | 447 +++++++++++++++++++++++++++++++ tests/routes/router.test.tsx | 208 ++++++++++++++ tests/stubs/routeTree.gen.ts | 2 + vitest.config.ts | 10 +- 6 files changed, 793 insertions(+), 5 deletions(-) create mode 100644 tests/routes/entry.test.tsx create mode 100644 tests/routes/routeFiles.test.tsx create mode 100644 tests/routes/router.test.tsx create mode 100644 tests/stubs/routeTree.gen.ts diff --git a/tests/routes/entry.test.tsx b/tests/routes/entry.test.tsx new file mode 100644 index 0000000..e0a8d94 --- /dev/null +++ b/tests/routes/entry.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const e = vi.hoisted(() => ({ + hydrateRoot: vi.fn(), + removeEdgeInjections: vi.fn(), + initThemeSentry: vi.fn(), + bootClient: vi.fn(), + createStart: vi.fn((factory: () => unknown) => factory()), + twilightMiddleware: vi.fn(() => 'twilight'), + earlyHintsMiddleware: vi.fn(() => 'early-hints'), + withThemeSentry: vi.fn((handler: unknown, name: string) => ({ handler, name })), +})); + +vi.mock('react-dom/client', () => ({ hydrateRoot: e.hydrateRoot })); +vi.mock('@tanstack/react-start/client', () => ({ StartClient: () => null })); +vi.mock('@salla.sa/twilight-theme-engine/sentry/client', () => ({ + initThemeSentry: e.initThemeSentry, +})); +vi.mock('@salla.sa/twilight-theme-engine/dom', () => ({ + removeEdgeInjections: e.removeEdgeInjections, +})); +vi.mock('@salla.sa/twilight-theme-engine/boot', () => ({ bootClient: e.bootClient })); +vi.mock('@salla.sa/twilight-theme-engine/sentry/server', () => ({ + withThemeSentry: e.withThemeSentry, +})); +vi.mock('@tanstack/react-start/server-entry', () => ({ default: 'server-handler' })); +vi.mock('@tanstack/react-start', () => ({ createStart: e.createStart })); +vi.mock('@salla.sa/twilight-theme-engine/tanstack', () => ({ + twilightMiddleware: e.twilightMiddleware, + earlyHintsMiddleware: e.earlyHintsMiddleware, +})); +vi.mock('../../app/hydrate', () => ({ hydrate: 'hydrate-fn' })); +vi.mock('../../app/styles/app.css?url', () => ({ default: '/app.css' })); + +beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); +}); + +describe('client entry', () => { + it('boots the client with the app stylesheet and the lazily loaded hydrator', async () => { + await import('../../app/client'); + const options = e.bootClient.mock.calls[0][0]; + expect(options.stylesheet).toBe('/app.css'); + expect((await options.load()).hydrate).toBe('hydrate-fn'); + }); +}); + +describe('hydrate', () => { + it('reports errors to the theme Sentry project', async () => { + await vi.importActual('../../app/hydrate'); + expect(e.initThemeSentry).toHaveBeenCalledWith('raed'); + }); + + it('strips edge-injected nodes, then hydrates the whole document', async () => { + const { hydrate } = + await vi.importActual('../../app/hydrate'); + hydrate(); + expect(e.removeEdgeInjections).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(e.hydrateRoot).toHaveBeenCalledTimes(1)); + expect(e.hydrateRoot.mock.calls[0][0]).toBe(document); + }); +}); + +describe('start and server entries', () => { + it('runs the twilight and early-hints middleware on every request', async () => { + const { startInstance } = await import('../../app/start'); + expect(startInstance).toEqual({ requestMiddleware: ['twilight', 'early-hints'] }); + }); + + it('wraps the server handler with theme Sentry', async () => { + const mod = await import('../../app/server'); + expect(mod.default).toEqual({ handler: 'server-handler', name: 'raed' }); + }); +}); + +describe('routes manifest', () => { + it('declares no custom routes', async () => { + const { routes } = await import('../../app/routes'); + expect(routes).toEqual([]); + }); +}); diff --git a/tests/routes/root.test.tsx b/tests/routes/root.test.tsx index 3eaaf71..53fa165 100644 --- a/tests/routes/root.test.tsx +++ b/tests/routes/root.test.tsx @@ -3,7 +3,8 @@ */ import { describe, it, expect, vi } from 'vitest'; import type { ComponentType } from 'react'; -import { renderToString } from 'react-dom/server'; +import { renderToString, renderToPipeableStream } from 'react-dom/server'; +import { Writable } from 'node:stream'; import type { ThemeStylesheetProps } from '@salla.sa/twilight-theme-engine/tanstack'; import { HOME_ROUTE_ID, PRODUCT_ROUTE_ID } from '../../app/lib/routeIds'; @@ -26,6 +27,9 @@ vi.mock('@salla.sa/twilight-theme-engine/tanstack', () => ({ }, ThemeHeadContent: () => null, })); +vi.mock('@salla.sa/twilight-theme-engine/dev', () => ({ + DevSettingsWidget: () => 'dev-settings-widget', +})); vi.mock('virtual:twilight/theme-translations', () => ({ default: {} })); vi.mock('virtual:twilight/schema', () => ({ default: {} })); vi.mock('../../app/components/layout/ThemeLayout', () => ({ ThemeLayout: () => null })); @@ -46,4 +50,47 @@ describe('root head', () => { [PRODUCT_ROUTE_ID]: '.pdp{color:blue}', }); }); + + it('offers the live settings editor in development', async () => { + const { Route } = await import('../../app/routes/__root'); + const Shell = (Route as unknown as { shellComponent: ComponentType }).shellComponent; + + let html = ''; + const sink = new Writable({ + write(chunk, _enc, done) { + html += chunk.toString(); + done(); + }, + }); + await new Promise((resolve) => { + const stream = renderToPipeableStream(, { + onAllReady: () => { + stream.pipe(sink); + sink.on('finish', resolve); + }, + }); + }); + + expect(html).toContain('dev-settings-widget'); + }); + + it('ships no critical CSS or settings editor in the client production bundle', async () => { + vi.stubEnv('DEV', false); + vi.stubEnv('SSR', false); + vi.resetModules(); + try { + const { Route } = await import('../../app/routes/__root'); + const Shell = (Route as unknown as { shellComponent: ComponentType }).shellComponent; + + const html = renderToString(); + + expect(rendered.stylesheet?.critical).toEqual({ + [HOME_ROUTE_ID]: '', + [PRODUCT_ROUTE_ID]: '', + }); + expect(html).not.toContain('dev-settings-widget'); + } finally { + vi.unstubAllEnvs(); + } + }); }); diff --git a/tests/routes/routeFiles.test.tsx b/tests/routes/routeFiles.test.tsx new file mode 100644 index 0000000..56351ed --- /dev/null +++ b/tests/routes/routeFiles.test.tsx @@ -0,0 +1,447 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from '@testing-library/react'; +import type { ComponentType } from 'react'; + +// Every engine export is replaced by a stub that records how a route file wired it: +// `loader` returns a marker, `Component` and the stub itself render a labelled element. +const h = vi.hoisted(async () => { + const { createElement } = await import('react'); + const cache = new Map(); + const state = { loaderData: {} as Record }; + + cache.set( + 'slugBrandRedirectLoader', + vi.fn((args: unknown) => ({ redirectedWith: args })) + ); + + const stub = (name: string) => { + if (!cache.has(name)) { + const render = (props: Record) => + createElement('div', { 'data-stub': name, 'data-props': JSON.stringify(props) }); + const fn = Object.assign((props: Record) => render(props), { + loader: vi.fn((args: unknown) => Promise.resolve({ loadedBy: name, args })), + Component: (props: Record) => render(props), + }); + cache.set(name, fn); + } + return cache.get(name) as ReturnType & { + loader: ReturnType; + }; + }; + + const moduleOf = () => + new Proxy( + {}, + { + has: () => true, + get: (_t, key) => { + if (key === 'then' || typeof key === 'symbol') return undefined; + if (key === 'withHead') return (entity: unknown) => ({ withHeadOf: entity }); + return stub(String(key)); + }, + } + ); + + return { cache, state, stub, moduleOf }; +}); + +vi.mock('@tanstack/react-router', async () => { + const { state } = await h; + return { + createFileRoute: (path: string) => (options: Record) => ({ + path, + ...options, + useLoaderData: () => state.loaderData, + }), + }; +}); +vi.mock('@salla.sa/twilight-theme-engine/tanstack', async () => (await h).moduleOf()); +vi.mock('@salla.sa/twilight-theme-engine/skeleton', async () => (await h).moduleOf()); +vi.mock('@salla.sa/twilight-theme-engine/routes/seo-redirects', async () => (await h).moduleOf()); +vi.mock('@salla.sa/twilight-theme-engine/routes/brands', async () => (await h).moduleOf()); +vi.mock('@salla.sa/twilight-theme-engine/routes/loyalty', async () => (await h).moduleOf()); +vi.mock('@salla.sa/twilight-theme-engine/routes/testimonials', async () => (await h).moduleOf()); +vi.mock('@salla.sa/twilight-theme-engine/routes/thank-you', async () => (await h).moduleOf()); +vi.mock('@salla.sa/twilight-theme-engine/routes/account', async () => (await h).moduleOf()); +vi.mock('@salla.sa/twilight-theme-engine/routes/account/orders', async () => (await h).moduleOf()); +vi.mock('@salla.sa/twilight-theme-engine/routes/blog', async () => (await h).moduleOf()); +vi.mock('@salla.sa/twilight-theme-engine/routes/home', async () => (await h).moduleOf()); +vi.mock('@salla.sa/twilight-theme-engine/routes/cart', async () => (await h).moduleOf()); +vi.mock('@salla.sa/twilight-theme-engine/routes/product', async () => (await h).moduleOf()); +vi.mock('@salla.sa/twilight-theme-engine/routes/page', async () => (await h).moduleOf()); +vi.mock('@salla.sa/twilight-theme-engine/api/page', async () => { + const { stub } = await h; + return { page: stub('pageApi') }; +}); +vi.mock('../../app/components/cart/CartPage', async () => (await h).moduleOf()); +vi.mock('../../app/components/product/ProductPage', async () => (await h).moduleOf()); +vi.mock('../../app/components/page/PageSingle', async () => (await h).moduleOf()); + +type RouteOptions = { + path: string; + loader?: (ctx: Record) => Promise; + head?: unknown; + pendingComponent?: ComponentType; + component: ComponentType; + validateSearch?: (search: Record) => unknown; + loaderDeps?: (ctx: { search: Record }) => unknown; +}; + +const load = async (file: string) => + (await import(`../../app/routes/${file}.tsx`)).Route as RouteOptions; + +const params = { locale: 'ar', id: '7', slug: 's', orderId: '99' }; + +type Case = { + file: string; + path: string; + entity: string; + skeleton?: string; + renders?: string; + expectArgs: Record; + ctx?: Record; +}; + +const localeOnly = { locale: 'ar' }; +const cases: Case[] = [ + { + file: 'index', + path: '/{-$locale}/', + entity: 'Home', + skeleton: 'HomeSkeleton', + expectArgs: localeOnly, + }, + { file: 'brands', path: '/{-$locale}/brands', entity: 'Brands', expectArgs: localeOnly }, + { file: 'loyalty', path: '/{-$locale}/loyalty', entity: 'Loyalty', expectArgs: localeOnly }, + { + file: 'testimonials', + path: '/{-$locale}/testimonials', + entity: 'Testimonials', + expectArgs: localeOnly, + }, + { + file: 'thankyou.$orderId', + path: '/{-$locale}/thankyou/$orderId', + entity: 'ThankYou', + expectArgs: { params: { orderId: '99' }, locale: 'ar' }, + }, + { + file: 'blog', + path: '/{-$locale}/blog', + entity: 'Blog', + skeleton: 'BlogSkeleton', + expectArgs: localeOnly, + }, + { + file: 'blog_.$slug.a-$id', + path: '/{-$locale}/blog/$slug/a-{$id}', + entity: 'BlogSingle', + skeleton: 'BlogSkeleton', + expectArgs: { params: { slug: 's', id: '7' }, locale: 'ar' }, + }, + { + file: 'blog_.$slug.c-$id', + path: '/{-$locale}/blog/$slug/c-{$id}', + entity: 'BlogCategoryRoute', + skeleton: 'BlogSkeleton', + expectArgs: { params: { slug: 's', id: '7' }, locale: 'ar' }, + }, + { + file: 'blog_.$slug.tag-$id', + path: '/{-$locale}/blog/$slug/tag-{$id}', + entity: 'BlogTagRoute', + skeleton: 'BlogSkeleton', + expectArgs: { params: { slug: 's', id: '7' }, locale: 'ar' }, + }, + { + file: 'blog_.author.$id', + path: '/{-$locale}/blog/author/$id', + entity: 'BlogAuthorRoute', + skeleton: 'BlogSkeleton', + expectArgs: { params: { id: '7' }, locale: 'ar' }, + }, + { + file: 'loyalty_points', + path: '/{-$locale}/loyalty_points', + entity: 'LoyaltyPoints', + skeleton: 'CustomerLayoutSkeleton', + expectArgs: localeOnly, + }, + { + file: 'account.profile', + path: '/{-$locale}/account/profile', + entity: 'Profile', + skeleton: 'CustomerPageSkeleton', + expectArgs: localeOnly, + }, + { + file: 'account.settings', + path: '/{-$locale}/account/settings', + entity: 'Settings', + skeleton: 'CustomerPageSkeleton', + expectArgs: localeOnly, + }, + { + file: 'account.notifications', + path: '/{-$locale}/account/notifications', + entity: 'Notifications', + skeleton: 'CustomerPageSkeleton', + expectArgs: localeOnly, + }, + { + file: 'account.orders.$id', + path: '/{-$locale}/account/orders/$id', + entity: 'OrderSingle', + skeleton: 'CustomerPageSkeleton', + expectArgs: { params: { id: '7' }, locale: 'ar' }, + }, +]; + +beforeEach(async () => { + const { state, cache } = await h; + state.loaderData = { marker: 'data' }; + cache.forEach((s) => (s as { loader?: { mockClear(): void } }).loader?.mockClear()); +}); + +describe.each(cases)('route $path', ({ file, path, entity, skeleton, expectArgs }) => { + it('is registered under its locale-aware path', async () => { + expect((await load(file)).path).toBe(path); + }); + + it('loads its page through the engine loader with the route params', async () => { + const { stub } = await h; + const route = await load(file); + const result = await route.loader!({ params }); + expect(stub(entity).loader).toHaveBeenCalledWith(expectArgs); + expect(result).toEqual({ loadedBy: entity, args: expectArgs }); + }); + + it('takes its page head from the engine entry', async () => { + const { stub } = await h; + expect((await load(file)).head).toEqual({ withHeadOf: stub(entity) }); + }); + + it('renders the engine component with the loaded data', async () => { + const route = await load(file); + const Component = route.component; + render(); + const el = document.querySelector(`[data-stub="${entity}"]`)!; + expect(JSON.parse(el.getAttribute('data-props')!)).toEqual({ marker: 'data' }); + }); + + if (skeleton) { + it(`shows ${skeleton} while loading`, async () => { + const Pending = (await load(file)).pendingComponent!; + render(); + expect(document.querySelector(`[data-stub="${skeleton}"]`)).not.toBeNull(); + }); + } else { + it('has no loading placeholder', async () => { + expect((await load(file)).pendingComponent).toBeUndefined(); + }); + } +}); + +describe('paginated account routes', () => { + describe.each([ + ['account.wallet', 'Wallet', { search: { page: 3 } }], + ['account.wishlist', 'Wishlist', { search: { page: 3 }, locale: 'ar' }], + ] as const)('%s', (file, entity, expected) => { + it('keeps only a page number above 1 in the URL', async () => { + const route = await load(file); + expect(route.validateSearch!({ page: '3' })).toEqual({ page: 3 }); + expect(route.validateSearch!({ page: '1' })).toEqual({}); + expect(route.validateSearch!({ page: 'abc' })).toEqual({}); + expect(route.validateSearch!({})).toEqual({}); + }); + + it('reloads when the page changes', async () => { + const route = await load(file); + expect(route.loaderDeps!({ search: { page: 2 } })).toEqual({ page: 2 }); + }); + + it('loads the requested page, defaulting to the first', async () => { + const { stub } = await h; + const route = await load(file); + await route.loader!({ deps: { page: 3 }, params }); + expect(stub(entity).loader).toHaveBeenCalledWith(expected); + await route.loader!({ deps: {}, params }); + expect(stub(entity).loader).toHaveBeenLastCalledWith({ + ...expected, + search: { page: 1 }, + }); + }); + + it('renders the engine component with a loading placeholder', async () => { + const route = await load(file); + const Component = route.component; + render(); + expect(document.querySelector(`[data-stub="${entity}"]`)).not.toBeNull(); + const Pending = route.pendingComponent!; + render(); + expect(document.querySelector('[data-stub="CustomerPageSkeleton"]')).not.toBeNull(); + }); + }); +}); + +describe('account.orders', () => { + it('filters by status only when one is given', async () => { + const route = await load('account.orders'); + expect(route.validateSearch!({ status: 'paid' })).toEqual({ status: 'paid' }); + expect(route.validateSearch!({ status: '' })).toEqual({}); + expect(route.validateSearch!({})).toEqual({}); + }); + + it('reloads when the status changes and passes it to the engine loader', async () => { + const { stub } = await h; + const route = await load('account.orders'); + expect(route.loaderDeps!({ search: { status: 'paid' } })).toEqual({ status: 'paid' }); + await route.loader!({ deps: { status: 'paid' }, params }); + expect(stub('Orders').loader).toHaveBeenCalledWith({ + search: { status: 'paid' }, + locale: 'ar', + }); + }); + + it('renders the engine component with a loading placeholder', async () => { + const route = await load('account.orders'); + const Component = route.component; + render(); + expect(document.querySelector('[data-stub="Orders"]')).not.toBeNull(); + const Pending = route.pendingComponent!; + render(); + expect(document.querySelector('[data-stub="CustomerPageSkeleton"]')).not.toBeNull(); + expect(route.head).toEqual({ withHeadOf: (await h).stub('Orders') }); + }); +}); + +describe('cart route', () => { + it('loads the cart and renders the theme cart page', async () => { + const { stub } = await h; + const route = await load('cart'); + await route.loader!({ params }); + expect(stub('Cart').loader).toHaveBeenCalledWith({ locale: 'ar' }); + expect(route.head).toEqual({ withHeadOf: stub('Cart') }); + const Component = route.component; + render(); + expect(document.querySelector('[data-stub="CartPage"]')).not.toBeNull(); + const Pending = route.pendingComponent!; + render(); + expect(document.querySelector('[data-stub="CartSkeleton"]')).not.toBeNull(); + }); +}); + +describe('product route', () => { + beforeEach(() => { + window.sessionStorage.setItem('breadcrumb-referrer', '/somewhere'); + }); + + it('drops the visitor referrer so the product keeps its own category trail', async () => { + const { stub } = await h; + const route = await load('$slug.p$id'); + await route.loader!({ params }); + expect(window.sessionStorage.getItem('breadcrumb-referrer')).toBeNull(); + expect(stub('Product').loader).toHaveBeenCalledWith({ params: { id: '7' }, locale: 'ar' }); + }); + + it('still loads the product when session storage is unavailable', async () => { + const { stub } = await h; + const route = await load('$slug.p$id'); + const spy = vi.spyOn(window, 'sessionStorage', 'get').mockImplementation(() => { + throw new Error('denied'); + }); + await expect(route.loader!({ params })).resolves.toEqual({ + loadedBy: 'Product', + args: { params: { id: '7' }, locale: 'ar' }, + }); + spy.mockRestore(); + expect(stub('Product').loader).toHaveBeenCalled(); + }); + + it('loads the product on the server, where there is no window', async () => { + const { stub } = await h; + const route = await load('$slug.p$id'); + vi.stubGlobal('window', undefined); + try { + await route.loader!({ params }); + } finally { + vi.unstubAllGlobals(); + } + expect(stub('Product').loader).toHaveBeenCalled(); + }); + + it('loads the product when the browser has no session storage', async () => { + const { stub } = await h; + const route = await load('$slug.p$id'); + vi.spyOn(window, 'sessionStorage', 'get').mockReturnValue(undefined as never); + await route.loader!({ params }); + expect(stub('Product').loader).toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it('remounts the product page for each product and shows the detail skeleton while loading', async () => { + const { state, stub } = await h; + state.loaderData = { product: { id: 5 } }; + const route = await load('$slug.p$id'); + expect(route.head).toEqual({ withHeadOf: stub('Product') }); + const Component = route.component; + render(); + expect(document.querySelector('[data-stub="ProductPage"]')).not.toBeNull(); + const Pending = route.pendingComponent!; + render(); + expect(document.querySelector('[data-stub="ProductDetailSkeleton"]')).not.toBeNull(); + }); +}); + +describe('static page route', () => { + it('maps the page API record to the page-single props', async () => { + const { stub } = await h; + const record = { + id: 7, + name: 'About', + url: '/about', + content: '

hi

', + metadata: { title: 't' }, + created_at: 'now', + type: 'page', + }; + stub('pageApi').findOrThrow = vi.fn().mockResolvedValue(record); + const route = await load('$slug.page-$id'); + await expect(route.loader!({ params })).resolves.toEqual({ + page: { + slug: 'page-single', + id: 7, + title: 'About', + url: '/about', + content: '

hi

', + metadata: { title: 't' }, + created_at: 'now', + type: 'page', + }, + }); + expect(stub('pageApi').findOrThrow).toHaveBeenCalledWith('7'); + expect(route.head).toEqual({ withHeadOf: stub('PageSingle') }); + const Component = route.component; + render(); + expect(document.querySelector('[data-stub="PageSingle"]')).not.toBeNull(); + }); +}); + +describe('redirect-only routes', () => { + it('sends legacy brand URLs through the engine redirect loader', async () => { + const { stub } = await h; + const route = await load('$slug.brand-$id'); + await expect(route.loader!({ params })).toEqual({ + redirectedWith: { params: { slug: 's', id: '7', locale: 'ar' } }, + }); + expect(stub('slugBrandRedirectLoader')).toHaveBeenCalledWith({ + params: { slug: 's', id: '7', locale: 'ar' }, + }); + }); + + it('logs out through the engine redirect component', async () => { + const { stub } = await h; + const route = await load('logout'); + expect(route.component).toBe(stub('LogoutRedirect')); + }); +}); diff --git a/tests/routes/router.test.tsx b/tests/routes/router.test.tsx new file mode 100644 index 0000000..f17210c --- /dev/null +++ b/tests/routes/router.test.tsx @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from '@testing-library/react'; +import type { ReactElement } from 'react'; + +const m = vi.hoisted(() => ({ + register: vi.fn(), + registerHomeComponents: vi.fn(), + registerHomeComponentConfig: vi.fn(), + registerThemeHooks: vi.fn(), + createRouter: vi.fn(() => ({ id: Symbol('router') })), +})); + +vi.mock('@salla.sa/twilight-theme-engine', () => ({ registry: { register: m.register } })); +vi.mock('@salla.sa/twilight-theme-engine/tanstack', () => ({ createRouter: m.createRouter })); +vi.mock('@salla.sa/twilight-theme-engine/routes/home', () => ({ + registerHomeComponents: m.registerHomeComponents, + registerHomeComponentConfig: m.registerHomeComponentConfig, + DefaultHomeComponents: { engineOnly: 'engine', brands: 'engine-brands' }, +})); +vi.mock('@salla.sa/twilight-components-react', () => ({ + SliderSkeleton: () =>
, +})); +vi.mock('../stubs/routeTree.gen', () => ({ routeTree: { tree: true } })); +vi.mock('../../app/hooks', () => ({ registerThemeHooks: m.registerThemeHooks })); +vi.mock('../../app/components/product/ProductListSkeleton', () => ({ + ProductListSkeleton: () => null, +})); +vi.mock('../../app/components/product/ProductListPage', () => ({ + ProductListPage: () =>
, +})); +vi.mock('../../app/components/home', () => { + const names = [ + 'Brands', + 'CustomTestimonials', + 'EnhancedSlider', + 'FeaturedProducts', + 'FixedProducts', + 'MainLinks', + 'PhotosSlider', + 'ProductsSlider', + 'SliderProductsWithHeader', + 'EnhancedSquareBanners', + ]; + return { + ...Object.fromEntries(names.map((n) => [n, `theme-${n}`])), + featuredProductsConfig: { className: 'featured-config' }, + }; +}); +vi.mock('../../app/components/home/Blog', () => ({ Blog: 'theme-Blog' })); +vi.mock('../../app/components/home/FeaturedProducts', () => ({ + FeaturedProductsSpecialOffer: 'theme-special-offer', + FeaturedProductsWithSpecialProduct: 'theme-special-product', +})); +vi.mock('../../app/components/home/PhotosSliderWithText', () => ({ + PhotosSliderWithText: 'theme-PhotosSliderWithText', +})); + +type BlockConfig = { + height?: string; + placeholder?: ReactElement; + className?: string | ((data: Record) => string); +}; + +const importRouter = () => import('../../app/router'); +const config = () => m.registerHomeComponentConfig.mock.calls[0][0] as Record; +const classOf = (key: string, data: Record) => + (config()[key].className as (d: Record) => string)(data); + +describe('theme registration', () => { + it('registers the theme hooks and the product list page and skeleton in the engine registry', async () => { + await importRouter(); + expect(m.registerThemeHooks).toHaveBeenCalledTimes(1); + const keys = m.register.mock.calls.map(([key]) => key); + expect(keys).toEqual(['product:list-page', 'product:list-skeleton']); + }); + + it('lazy-loads the product list page', async () => { + await importRouter(); + const LazyPage = m.register.mock.calls[0][1]; + const { findByTestId } = render(); + expect(await findByTestId('list-page')).toBeTruthy(); + }); + + it('keeps the engine home blocks and overrides them with the theme blocks', async () => { + await importRouter(); + const registered = m.registerHomeComponents.mock.calls[0][0]; + expect(registered.engineOnly).toBe('engine'); + expect(registered.brands).toBe('theme-Brands'); + expect(registered['main-links']).toBe('theme-MainLinks'); + expect(registered['square-links']).toBe('theme-MainLinks'); + expect(registered.blog).toBe('theme-Blog'); + }); + + it('serves every featured-products view style from the theme component', async () => { + await importRouter(); + const registered = m.registerHomeComponents.mock.calls[0][0]; + for (const key of [ + 'featured-products', + 'featured-products:style1', + 'featured-products:style2', + 'featured-products:style3', + ]) { + expect(registered[key]).toBe('theme-FeaturedProducts'); + } + expect(registered['featured-products-special-offer']).toBe('theme-special-offer'); + expect(registered['featured-products-special-product']).toBe('theme-special-product'); + }); +}); + +describe('home block placements', () => { + it('gives the featured-products layouts the shared wrapper config', async () => { + await importRouter(); + for (const key of [ + 'featured-products', + 'featured-products-special-offer', + 'featured-products-special-product', + ]) { + expect(config()[key]).toEqual({ className: 'featured-config' }); + } + }); + + it('runs the blog and testimonials as edge-to-edge bands', async () => { + await importRouter(); + expect(config().blog.className).toContain('s-block--full-bg'); + expect(config().blog.height).toBe('clamp(480px, 45vw, 660px)'); + expect(config()['custom-testimonials'].className).toContain('s-block--full-bg'); + }); + + it('reserves slider skeleton space for lazily placed blocks', async () => { + await importRouter(); + for (const key of [ + 'enhanced-slider', + 'main-links', + 'square-links', + 'photos-slider', + 'photos-slider-with-text', + ]) { + expect(config()[key].height).toBeTruthy(); + const { getByTestId, unmount } = render(config()[key].placeholder!); + expect(getByTestId('slider-skeleton')).toBeTruthy(); + unmount(); + } + }); + + it('marks the boxed enhanced slider only when it sits in a container', async () => { + await importRouter(); + expect(classOf('enhanced-slider', {})).toBe('s-block s-block--enhanced-slider'); + expect(classOf('enhanced-slider', { in_container: true })).toBe( + 's-block s-block--enhanced-slider s-block--enhanced-slider-boxed' + ); + }); + + it('merges main links with the block above only when asked and positioned', async () => { + await importRouter(); + expect(classOf('main-links', {})).toBe('s-block s-block--main-links'); + expect(classOf('main-links', { merge_links_with_top_component: true, position: 2 })).toBe( + 's-block s-block--main-links merge-with-top-component merged-has-no-title' + ); + expect( + classOf('main-links', { merge_links_with_top_component: true, position: 2, title: 'T' }) + ).toBe('s-block s-block--main-links merge-with-top-component'); + expect(classOf('main-links', { merge_links_with_top_component: true, title: 'T' })).toBe( + 's-block s-block--main-links' + ); + }); + + it('flags repeated photo sliders', async () => { + await importRouter(); + expect(classOf('photos-slider', {})).toBe('s-block s-block--photos-slider'); + expect(classOf('photos-slider', { is_repeated: true })).toBe( + 's-block s-block--photos-slider repeated-block' + ); + }); + + it('marks the boxed photos-with-text slider only when it sits in a container', async () => { + await importRouter(); + expect(classOf('photos-slider-with-text', {})).toBe('s-block s-block--photos-slider-with-text'); + expect(classOf('photos-slider-with-text', { in_container: true })).toBe( + 's-block s-block--photos-slider-with-text s-block--photos-slider-with-text-boxed' + ); + }); +}); + +describe('getRouter', () => { + beforeEach(() => { + vi.resetModules(); + m.createRouter.mockClear(); + }); + + it('reuses one router in the browser so the query cache survives navigation', async () => { + const { getRouter } = await importRouter(); + const first = getRouter(); + expect(getRouter()).toBe(first); + expect(m.createRouter).toHaveBeenCalledTimes(1); + expect(m.createRouter).toHaveBeenCalledWith({ tree: true }); + }); + + it('builds a fresh router per request on the server', async () => { + vi.stubGlobal('window', undefined); + try { + const { getRouter } = await importRouter(); + expect(getRouter()).not.toBe(getRouter()); + expect(m.createRouter).toHaveBeenCalledTimes(2); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/tests/stubs/routeTree.gen.ts b/tests/stubs/routeTree.gen.ts new file mode 100644 index 0000000..b7d3557 --- /dev/null +++ b/tests/stubs/routeTree.gen.ts @@ -0,0 +1,2 @@ +// Stands in for the router plugin's generated tree, which is gitignored and absent on a clean checkout. +export const routeTree = { stub: true }; diff --git a/vitest.config.ts b/vitest.config.ts index 27bd3f7..71052fb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,8 @@ export default defineConfig({ dedupe: ['react', 'react-dom', 'react/jsx-runtime'], // Only the engine's Vite plugin serves this; tests get the locale files directly. alias: { + // Generated by the router plugin at build time; absent on a clean checkout. + './routeTree.gen': fileURLToPath(new URL('./tests/stubs/routeTree.gen.ts', import.meta.url)), 'virtual:twilight/theme-translations': fileURLToPath( new URL('./tests/stubs/theme-translations.ts', import.meta.url) ), @@ -38,10 +40,10 @@ export default defineConfig({ // Re-measured once after formatting: app/router.tsx failed to parse under coverage before, // so the first floor silently left out its 128 untested lines. thresholds: { - lines: 74, - branches: 70, - functions: 64, - statements: 72, + lines: 88, + branches: 75, + functions: 83, + statements: 85, }, }, reporters: ['default', 'json'], From 1bc1d0a6276b66e65d80f646a66a6718b9b077e2 Mon Sep 17 00:00:00 2001 From: Salah Al-Nawah Date: Mon, 26 Oct 2026 21:07:24 +0000 Subject: [PATCH 2/3] test(SAL-3525): exclude the generated route tree from coverage CI generates app/routeTree.gen.ts before testing; it is generated code and was counted at 0%. Made-with: agent Co-authored-by: mc-agents[bot] <298817976+mc-agents[bot]@users.noreply.github.com> --- vitest.config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/vitest.config.ts b/vitest.config.ts index 71052fb..e33c762 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -34,7 +34,11 @@ export default defineConfig({ provider: 'v8', reporter: ['text', 'json', 'html', 'lcov'], include: ['app/**/*.{ts,tsx}'], - exclude: ['app/**/*.d.ts'], + exclude: [ + 'app/**/*.d.ts', + // Generated by the router plugin at build time; not hand-written code. + 'app/routeTree.gen.ts', + ], // Coverage floor (ratchet): measured on app/** and rounded down. It only goes up — // raise it when coverage improves, never lower it to make a change pass. // Re-measured once after formatting: app/router.tsx failed to parse under coverage before, From 9957848f04a73745c2ccfcd1195b30412694e40a Mon Sep 17 00:00:00 2001 From: Salah Al-Nawah Date: Tue, 27 Oct 2026 10:25:05 +0000 Subject: [PATCH 3/3] test(SAL-3525): wait for the lazy settings editor before asserting it is absent in production Made-with: agent Co-authored-by: mc-agents[bot] <298817976+mc-agents[bot]@users.noreply.github.com> --- tests/routes/root.test.tsx | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/tests/routes/root.test.tsx b/tests/routes/root.test.tsx index 53fa165..0746057 100644 --- a/tests/routes/root.test.tsx +++ b/tests/routes/root.test.tsx @@ -37,6 +37,25 @@ vi.mock('../../app/styles/app.css?url', () => ({ default: '/app.css' })); vi.mock('../../app/styles/critical.scss?inline', () => ({ default: '.home{color:red}' })); vi.mock('../../app/styles/critical-product.scss?inline', () => ({ default: '.pdp{color:blue}' })); +const renderWhenReady = async (Shell: ComponentType) => { + let html = ''; + const sink = new Writable({ + write(chunk, _enc, done) { + html += chunk.toString(); + done(); + }, + }); + await new Promise((resolve) => { + const stream = renderToPipeableStream(, { + onAllReady: () => { + stream.pipe(sink); + sink.on('finish', resolve); + }, + }); + }); + return html; +}; + describe('root head', () => { it('paints the home and product pages from their own critical CSS', async () => { const { Route } = await import('../../app/routes/__root'); @@ -55,21 +74,7 @@ describe('root head', () => { const { Route } = await import('../../app/routes/__root'); const Shell = (Route as unknown as { shellComponent: ComponentType }).shellComponent; - let html = ''; - const sink = new Writable({ - write(chunk, _enc, done) { - html += chunk.toString(); - done(); - }, - }); - await new Promise((resolve) => { - const stream = renderToPipeableStream(, { - onAllReady: () => { - stream.pipe(sink); - sink.on('finish', resolve); - }, - }); - }); + const html = await renderWhenReady(Shell); expect(html).toContain('dev-settings-widget'); }); @@ -82,7 +87,7 @@ describe('root head', () => { const { Route } = await import('../../app/routes/__root'); const Shell = (Route as unknown as { shellComponent: ComponentType }).shellComponent; - const html = renderToString(); + const html = await renderWhenReady(Shell); expect(rendered.stylesheet?.critical).toEqual({ [HOME_ROUTE_ID]: '',