test(desktop): late-registered plugin route wins over the chat catch-all

Regression test from #111541: mounts ChatRoutesSurface at a not-yet-registered
path through the real useContributions + registry under the compiler-enabled
vitest ui project, registers the route after mount, and asserts the page
replaces the `:sessionId` chat fallback. Red on origin/main, green with the
snapshot-fed contributedRoutes(). Replaces the equivalent surface test from
#109418 so the workspace surface carries one invariant test.
This commit is contained in:
rahlquist
2026-09-15 11:29:10 -07:00
committed by Teknium
parent 6fc7032aac
commit adbf2103cd
@@ -1,3 +1,12 @@
/**
* A plugin page route registered AFTER the workspace surface mounts must
* become navigable. Regression for late-loaded desktop plugins (disk plugins
* load async): the route table was compiled into a memo slot keyed on
* unrelated props, so a late `routes`-area registration never entered the
* table — the sidebar row rendered but navigating to the path fell through
* to the `:sessionId` chat route. Uses the REAL useContributions + registry
* (unlike surfaces.test.tsx) because the reactive flow is the subject.
*/
import { act, cleanup, render, screen } from '@testing-library/react'
import { atom } from 'nanostores'
import { MemoryRouter } from 'react-router'
@@ -8,13 +17,6 @@ import { registry } from '@/contrib/registry'
import { ChatRoutesSurface } from './surfaces'
import type { WiringActions } from './types'
// #109063: the workspace surface subscribes to ROUTES_AREA but used to derive
// routes via an independently-called `contributedRoutes()`, which React
// Compiler can keep memoized across the delivered subscription update. The
// registry, `useContributions` and the routes module all stay REAL here — only
// unrelated stores and heavy sibling views are stubbed, because mocking the
// route list is exactly what hid the defect.
vi.mock('@/store/connections', () => ({ $activeConnectionId: atom('local') }))
vi.mock('@/store/gateway', () => ({ $gateway: atom<unknown>(null) }))
vi.mock('@/store/profile', () => ({ $activeGatewayProfile: atom('default') }))
@@ -30,61 +32,44 @@ vi.mock('../shell/hooks/use-statusbar-items', () => ({
useStatusbarItems: () => ({ leftStatusbarItems: [], statusbarItems: [] })
}))
vi.mock('../shell/statusbar-controls', () => ({ StatusbarControls: () => null }))
vi.mock('../shell/model-menu-panel', () => ({ ModelMenuPanel: () => null }))
vi.mock('./latest-actions', () => ({ latestChatActions: () => ({}), latestSidebarActions: () => ({}) }))
vi.mock('./panes', () => ({ setStatusbarItemGroup: vi.fn(), useStatusbarContributions: () => [] }))
vi.mock('../shell/model-menu-panel', () => ({ ModelMenuPanel: () => null }))
vi.mock('../shell/reasoning-menu-panel', () => ({ ReasoningMenuPanel: () => null }))
afterEach(cleanup)
afterEach(() => {
cleanup()
})
/** Register (or hot-replace) the plugin route page mounted at `/resetwatch`. */
function registerResetwatch(label: string) {
return registry.register({
id: 'resetwatch',
area: 'routes',
title: `Resetwatch ${label}`,
data: { path: '/resetwatch' },
render: () => <div>{`PLUGIN-PAGE-${label}`}</div>
})
}
describe('ChatRoutesSurface route-contribution reactivity (#109063)', () => {
it('mounts a plugin page registered after the surface, tracks replacement, and unmounts on dispose', () => {
const actions = { getGateway: () => null } as unknown as WiringActions
describe('ChatRoutesSurface late-registered plugin routes', () => {
it('renders a page whose route registers after mount', () => {
const actions = {} as unknown as WiringActions
render(
<MemoryRouter initialEntries={['/resetwatch']}>
<MemoryRouter initialEntries={['/late-plugin']}>
<ChatRoutesSurface actions={actions} />
</MemoryRouter>
)
// Unregistered path falls through the catch-all redirect to the chat view.
// Before registration the path falls through to the chat catch-all.
expect(screen.queryByTestId('late-page')).toBeNull()
expect(screen.getByTestId('chat-view')).toBeTruthy()
let dispose = () => {}
act(() => {
// Late registration after the surface has mounted and settled.
dispose = registerResetwatch('A')
dispose = registry.register({
area: 'routes',
id: 'late-plugin:page',
data: { path: '/late-plugin' },
render: () => <div data-testid="late-page" />
})
})
expect(screen.getByText('PLUGIN-PAGE-A')).toBeTruthy()
// The late registration must reach the route table and win over the
// `:sessionId` dynamic route.
expect(screen.getByTestId('late-page')).toBeTruthy()
expect(screen.queryByTestId('chat-view')).toBeNull()
act(() => {
dispose()
dispose = registerResetwatch('B')
})
expect(screen.getByText('PLUGIN-PAGE-B')).toBeTruthy()
act(() => {
dispose()
})
expect(screen.getByTestId('chat-view')).toBeTruthy()
act(() => {
dispose = registerResetwatch('A')
})
expect(screen.getByText('PLUGIN-PAGE-A')).toBeTruthy()
act(() => {
dispose()
})
act(() => dispose())
})
})