189 lines
6.9 KiB
JavaScript
189 lines
6.9 KiB
JavaScript
/* Test-only entry. Kept inside src/ so every import resolves through Vite's
|
|
module graph exactly as it does in the app — one React instance, one router
|
|
instance, one query client. Not shipped: excluded from the build because
|
|
nothing in the app imports it. */
|
|
|
|
import React from 'react'
|
|
import { act } from 'react'
|
|
import { createRoot } from 'react-dom/client'
|
|
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom'
|
|
import { QueryClientProvider } from '@tanstack/react-query'
|
|
|
|
import { queryClient } from '../lib/queryClient'
|
|
import { initializeCache } from '../data/seedQueries'
|
|
import ThemeProvider, { initTheme } from '../theme/ThemeProvider'
|
|
import ToastProvider from '../ui/Toast'
|
|
import AuthProvider from '../auth/AuthProvider'
|
|
import AppLayout from '../app/AppLayout'
|
|
import RequireAuth from '../auth/RequireAuth'
|
|
import { ROUTES as TABLE } from '../app/routes'
|
|
|
|
import Login from '../pages/Login'
|
|
import Signup from '../pages/Signup'
|
|
import ForgotPassword from '../pages/ForgotPassword'
|
|
import ConfirmEmail from '../pages/ConfirmEmail'
|
|
|
|
import Dashboard from '../screens/Dashboard'
|
|
import Inbox from '../screens/Inbox'
|
|
import Matching from '../screens/Matching'
|
|
import Jobs from '../screens/Jobs'
|
|
import Candidates from '../screens/Candidates'
|
|
import CvBank from '../screens/CvBank'
|
|
import Pipeline from '../screens/Pipeline'
|
|
import Progress from '../screens/Progress'
|
|
import CvImport from '../screens/CvImport'
|
|
import JobBoard from '../screens/JobBoard'
|
|
import RecruiterHub from '../screens/RecruiterHub'
|
|
import Talent from '../screens/Talent'
|
|
import Tasks from '../screens/Tasks'
|
|
import AiAssistant from '../screens/AiAssistant'
|
|
import Interviews from '../screens/Interviews'
|
|
import Requisitions from '../screens/Requisitions'
|
|
import Assessments from '../screens/Assessments'
|
|
import Offers from '../screens/Offers'
|
|
import Managers from '../screens/Managers'
|
|
import Calendar from '../screens/Calendar'
|
|
import Reports from '../screens/Reports'
|
|
import Analytics from '../screens/Analytics'
|
|
import AiStudio from '../screens/AiStudio'
|
|
import Notifications from '../screens/Notifications'
|
|
import Rbac from '../screens/Rbac'
|
|
import Settings from '../screens/Settings'
|
|
import Help from '../screens/Help'
|
|
import JobProfile from '../screens/JobProfile'
|
|
|
|
const SCREENS = {
|
|
dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates,
|
|
cvbank: CvBank, pipeline: Pipeline, progress: Progress, import: CvImport, jobboard: JobBoard,
|
|
recruiterhub: RecruiterHub, talent: Talent, tasks: Tasks, aiassistant: AiAssistant,
|
|
interviews: Interviews, requisitions: Requisitions, assessments: Assessments, offers: Offers,
|
|
managers: Managers, calendar: Calendar, reports: Reports, analytics: Analytics,
|
|
aistudio: AiStudio, notifications: Notifications, rbac: Rbac,
|
|
settings: Settings, help: Help,
|
|
}
|
|
|
|
const PAGES = {
|
|
'/auth/login': Login,
|
|
'/auth/signup': Signup,
|
|
'/auth/forgot-password': ForgotPassword,
|
|
'/auth/confirm-email': ConfirmEmail,
|
|
}
|
|
|
|
// Detail pages live outside the ROUTES table (parameterized path), as in App.jsx.
|
|
const DETAIL_PAGES = [
|
|
{ pattern: '/job/:jobId', prefix: '/job/', Screen: JobProfile },
|
|
]
|
|
|
|
export const ALL_ROUTES = [
|
|
...Object.keys(PAGES),
|
|
...TABLE.map((r) => `/${r.path}`),
|
|
]
|
|
|
|
export function boot() {
|
|
initTheme()
|
|
initializeCache(queryClient)
|
|
}
|
|
|
|
/**
|
|
* Like renderRoute, but hands the mount BACK instead of tearing it down.
|
|
*
|
|
* renderRoute answers "did this route render at all". The Inbox loading test
|
|
* asks a different question — what is on screen between one fetch resolving
|
|
* and the next — so it needs to step time itself and read the DOM at each
|
|
* step. `settle` must come from here, not the test file, because act() has to
|
|
* be the bundle's React, not a second copy.
|
|
*/
|
|
export async function mountRoute(path, container) {
|
|
const tree = routeTree(path)
|
|
const root = createRoot(container)
|
|
await act(async () => { root.render(tree) })
|
|
const settle = async (ms = 20) => {
|
|
await act(async () => { await new Promise((r) => setTimeout(r, ms)) })
|
|
}
|
|
await settle()
|
|
|
|
// Interaction helpers live here for the same reason `settle` does: act() has
|
|
// to be the bundle's React instance, not a second copy imported by the test.
|
|
const click = async (el) => {
|
|
if (!el) throw new Error('click: element not found')
|
|
await act(async () => { el.click() })
|
|
await settle()
|
|
}
|
|
const selectOption = async (el, value) => {
|
|
if (!el) throw new Error('selectOption: element not found')
|
|
const Ev = el.ownerDocument.defaultView.Event
|
|
await act(async () => {
|
|
el.value = value
|
|
el.dispatchEvent(new Ev('change', { bubbles: true }))
|
|
})
|
|
await settle()
|
|
}
|
|
// React tracks an input's value through the native setter; assigning
|
|
// el.value directly is invisible to onChange, so go through the prototype.
|
|
const type = async (el, value) => {
|
|
if (!el) throw new Error('type: element not found')
|
|
const win = el.ownerDocument.defaultView
|
|
await act(async () => {
|
|
Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, 'value').set.call(el, value)
|
|
el.dispatchEvent(new win.Event('input', { bubbles: true }))
|
|
})
|
|
await settle()
|
|
}
|
|
|
|
return {
|
|
settle,
|
|
type,
|
|
click,
|
|
selectOption,
|
|
html: () => container.innerHTML,
|
|
text: () => container.textContent || '',
|
|
find: (selector) => container.querySelector(selector),
|
|
findByText: (selector, text) => [...container.querySelectorAll(selector)]
|
|
.find((el) => (el.textContent || '').includes(text)) || null,
|
|
unmount: async () => { await act(async () => { root.unmount() }) },
|
|
}
|
|
}
|
|
|
|
function routeTree(path) {
|
|
const h = React.createElement
|
|
const isAuth = path.startsWith('/auth/')
|
|
const detail = DETAIL_PAGES.find((d) => path.startsWith(d.prefix))
|
|
const def = TABLE.find((r) => `/${r.path}` === path.split('?')[0])
|
|
const Screen = isAuth ? PAGES[path] : detail ? detail.Screen : SCREENS[def.path]
|
|
const routePath = detail ? detail.pattern : path.split('?')[0]
|
|
|
|
const inner = isAuth
|
|
? h(Route, { path, element: h(Screen) })
|
|
: h(
|
|
Route,
|
|
{ element: h(RequireAuth, null, h(AppLayout)) },
|
|
h(Route, { path: routePath, element: h(Screen) }),
|
|
)
|
|
|
|
return h(
|
|
QueryClientProvider, { client: queryClient },
|
|
h(ThemeProvider, null,
|
|
h(ToastProvider, null,
|
|
h(MemoryRouter, { initialEntries: [path] },
|
|
h(AuthProvider, null,
|
|
h(Routes, null, inner, h(Route, { path: '*', element: h(Navigate, { to: path, replace: true }) })),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
/** Mount one route, wait for effects to settle, return its rendered text. */
|
|
export async function renderRoute(path, container) {
|
|
const tree = routeTree(path)
|
|
const root = createRoot(container)
|
|
try {
|
|
await act(async () => { root.render(tree) })
|
|
await act(async () => { await new Promise((r) => setTimeout(r, 40)) })
|
|
return container.textContent || ''
|
|
} finally {
|
|
await act(async () => { root.unmount() })
|
|
}
|
|
}
|