Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into add_range
commit
75943cf03f
|
|
@ -0,0 +1,108 @@
|
|||
/* ============================================================
|
||||
mobile.test.mjs — mobile-viewport regression test.
|
||||
|
||||
Opens every app route at phone/tablet widths against the running dev
|
||||
server, logs in as the local seeded tester, and fails if any route lets
|
||||
the main pane scroll sideways (the classic clipped-card / blown-out-grid
|
||||
symptom) or throws a runtime error. Intended horizontal scrollers
|
||||
(.table-wrap, .kanban, .tabs, .stepper) are exempt by design: they scroll
|
||||
inside themselves, never the pane.
|
||||
|
||||
Requirements (not part of the default `npm run verify` chain):
|
||||
- dev server running (npm run dev) and the backend reachable
|
||||
- Chrome installed (CHROME_PATH overrides the default location)
|
||||
- puppeteer-core available: npm i -D puppeteer-core (no download;
|
||||
it drives the installed Chrome)
|
||||
|
||||
Run: node mobile.test.mjs
|
||||
Env: ATS_BASE_URL, CHROME_PATH, ATS_TEST_EMAIL, ATS_TEST_PASSWORD
|
||||
============================================================ */
|
||||
|
||||
let puppeteer
|
||||
try {
|
||||
puppeteer = (await import('puppeteer-core')).default
|
||||
} catch {
|
||||
console.error('puppeteer-core is not installed. Run: npm i -D puppeteer-core')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const BASE = process.env.ATS_BASE_URL || 'http://localhost:5173'
|
||||
const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
|
||||
const EMAIL = process.env.ATS_TEST_EMAIL || 'ats.tester@example.com'
|
||||
const PASSWORD = process.env.ATS_TEST_PASSWORD || 'Test12345!'
|
||||
|
||||
// Keep in sync with src/app/routes.js (paths only — titles don't matter here).
|
||||
const ROUTES = [
|
||||
'dashboard', 'inbox', 'matching', 'jobs', 'candidates', 'talentpool', 'pipeline',
|
||||
'progress', 'import', 'jobboard', 'recruiterhub', 'talent', 'tasks', 'aiassistant',
|
||||
'interviews', 'requisitions', 'assessments', 'offers', 'managers', 'calendar',
|
||||
'reports', 'analytics', 'aistudio', 'notifications', 'rbac', 'settings', 'help',
|
||||
]
|
||||
const WIDTHS = [320, 375, 390, 430, 768]
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME,
|
||||
headless: true,
|
||||
args: ['--no-sandbox'],
|
||||
defaultViewport: { width: 320, height: 800, isMobile: true, hasTouch: true },
|
||||
})
|
||||
|
||||
const failures = []
|
||||
const runtimeErrors = []
|
||||
try {
|
||||
const page = await browser.newPage()
|
||||
page.on('pageerror', (e) => runtimeErrors.push(`${page.url()}: ${e.message.split('\n')[0]}`))
|
||||
|
||||
await page.goto(`${BASE}/auth/login`, { waitUntil: 'domcontentloaded', timeout: 30000 })
|
||||
await sleep(1000)
|
||||
if (await page.$('input[type=password]')) {
|
||||
const email = await page.$('input:not([type=password])')
|
||||
await email.type(EMAIL)
|
||||
await page.type('input[type=password]', PASSWORD)
|
||||
await Promise.all([
|
||||
page.click('button[type=submit]'),
|
||||
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {}),
|
||||
])
|
||||
await sleep(1500)
|
||||
}
|
||||
|
||||
for (const route of ROUTES) {
|
||||
for (const w of WIDTHS) {
|
||||
await page.setViewport({ width: w, height: 800, isMobile: w < 768, hasTouch: w < 768 })
|
||||
await page.goto(`${BASE}/${route}`, { waitUntil: 'domcontentloaded', timeout: 30000 })
|
||||
await sleep(1500)
|
||||
let rep
|
||||
try {
|
||||
rep = await page.evaluate(() => {
|
||||
const doc = document.documentElement
|
||||
const content = document.querySelector('.content')
|
||||
return {
|
||||
docOverflow: doc.scrollWidth - doc.clientWidth,
|
||||
contentOverflow: content ? content.scrollWidth - content.clientWidth : 0,
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
await sleep(1500)
|
||||
continue // page navigated mid-evaluate; the next loop iteration re-covers it
|
||||
}
|
||||
if (rep.docOverflow > 1) failures.push(`${route} @${w}: document scrolls sideways by ${rep.docOverflow}px`)
|
||||
if (rep.contentOverflow > 1) failures.push(`${route} @${w}: .content scrolls sideways by ${rep.contentOverflow}px`)
|
||||
}
|
||||
process.stdout.write('.')
|
||||
}
|
||||
console.log('')
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
// 4xx/5xx fetch noise is backend behaviour, not a layout bug — only genuine
|
||||
// script errors (pageerror) fail the run.
|
||||
if (failures.length || runtimeErrors.length) {
|
||||
for (const f of failures) console.error('FAIL ' + f)
|
||||
for (const e of runtimeErrors) console.error('ERROR ' + e)
|
||||
console.error(`\n${failures.length} overflow failure(s), ${runtimeErrors.length} runtime error(s)`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`All ${ROUTES.length} routes clean at ${WIDTHS.join('/')}px — no sideways scroll, no runtime errors`)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -11,6 +11,7 @@
|
|||
"smoke": "node smoke.test.mjs",
|
||||
"test:token": "node token.test.mjs",
|
||||
"test:theme": "node theme.test.mjs",
|
||||
"test:mobile": "node mobile.test.mjs",
|
||||
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -24,6 +25,7 @@
|
|||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"jsdom": "^30.0.1",
|
||||
"puppeteer-core": "^23.11.1",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -732,7 +732,8 @@ export default function Inbox() {
|
|||
const formTabFilter = FORM_TAB_FILTERS[tab] ?? {}
|
||||
const listParams = useMemo(() => ({
|
||||
...tabFilter,
|
||||
top: pageSize,
|
||||
// 'all' drops the param entirely — the endpoint reads a missing top as unpaged.
|
||||
top: pageSize === 'all' ? undefined : pageSize,
|
||||
skip,
|
||||
...(q.trim() ? { search: q.trim() } : {}),
|
||||
}), [tabFilter, skip, pageSize, q])
|
||||
|
|
@ -740,7 +741,7 @@ export default function Inbox() {
|
|||
const formParams = useMemo(() => ({
|
||||
sheet: formSheet || undefined,
|
||||
offset: skip,
|
||||
limit: pageSize,
|
||||
limit: pageSize === 'all' ? undefined : pageSize,
|
||||
...formTabFilter,
|
||||
...(q.trim() ? { search: q.trim() } : {}),
|
||||
}), [formSheet, skip, pageSize, q, formTabFilter])
|
||||
|
|
@ -841,15 +842,19 @@ export default function Inbox() {
|
|||
const total = q.trim()
|
||||
? (activeQuery.data?.total ?? 0)
|
||||
: (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0)))
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
// 'all' = unpaged: the fetch omits top/limit (the endpoints treat a missing
|
||||
// page size as no LIMIT), so the whole tab is one page.
|
||||
const showAll = pageSize === 'all'
|
||||
const pages = showAll ? 1 : Math.max(1, Math.ceil(total / pageSize))
|
||||
const from = total ? skip + 1 : 0
|
||||
const to = Math.min(skip + pageSize, total)
|
||||
const currentPage = Math.min(Math.floor(skip / pageSize) + 1, pages)
|
||||
const to = showAll ? total : Math.min(skip + pageSize, total)
|
||||
const currentPage = showAll ? 1 : Math.min(Math.floor(skip / pageSize) + 1, pages)
|
||||
|
||||
useEffect(() => {
|
||||
if (showAll) { if (skip !== 0) setSkip(0); return }
|
||||
if (total <= 0 || skip < total) return
|
||||
setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize))
|
||||
}, [total, pageSize, skip])
|
||||
}, [total, pageSize, skip, showAll])
|
||||
|
||||
const list = inbox
|
||||
|
||||
|
|
@ -1208,12 +1213,14 @@ export default function Inbox() {
|
|||
total={total}
|
||||
page={currentPage}
|
||||
pages={pages}
|
||||
setPage={(p) => { setSkip((p - 1) * pageSize); setSelectedId(null); selection.clear() }}
|
||||
setPage={(p) => { if (showAll) return; setSkip((p - 1) * pageSize); setSelectedId(null); selection.clear() }}
|
||||
pageButtons={pageWindow(currentPage, pages)}
|
||||
pageSize={pageSize}
|
||||
pageSizeMax={PAGE_SIZE_MAX}
|
||||
allowAll
|
||||
onPageSizeChange={(n) => {
|
||||
setPageSize(n)
|
||||
if (n === 'all') setSkip(0) // numeric sizes keep their page (clamp effect above)
|
||||
setSelectedId(null)
|
||||
selection.clear()
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -110,10 +110,14 @@ export function pageAfterSizeChange(currentPage, total, nextSize) {
|
|||
return Math.min(Math.max(1, currentPage || 1), pages)
|
||||
}
|
||||
|
||||
/** Per page dropdown: 10, 50, 100 (values above `max` are omitted). */
|
||||
export function PageSizeField({ value, onChange, max = 100, label = 'Per page', id }) {
|
||||
/** Per page dropdown: 10, 50, 100 (values above `max` are omitted).
|
||||
With `allowAll`, an extra "All" entry reports the sentinel 'all' — the
|
||||
caller drops its top/limit param, which the inbox endpoints read as
|
||||
unpaged (Query(None) -> no LIMIT). Opt-in per screen. */
|
||||
export function PageSizeField({ value, onChange, max = 100, label = 'Per page', id, allowAll = false }) {
|
||||
const options = PAGE_SIZE_OPTIONS.filter((n) => n <= max)
|
||||
const selected = clampPageSize(value, max)
|
||||
const isAll = allowAll && value === 'all'
|
||||
const selected = isAll ? 'all' : clampPageSize(value, max)
|
||||
|
||||
return (
|
||||
<label className="page-size">
|
||||
|
|
@ -122,12 +126,13 @@ export function PageSizeField({ value, onChange, max = 100, label = 'Per page',
|
|||
id={id}
|
||||
className="select page-size-select"
|
||||
value={selected}
|
||||
onChange={(e) => onChange(clampPageSize(e.target.value, max))}
|
||||
onChange={(e) => onChange(e.target.value === 'all' ? 'all' : clampPageSize(e.target.value, max))}
|
||||
aria-label={label}
|
||||
>
|
||||
{options.map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
{allowAll && <option value="all">All</option>}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
|
|
@ -135,7 +140,7 @@ export function PageSizeField({ value, onChange, max = 100, label = 'Per page',
|
|||
|
||||
export function Pagination({
|
||||
from, to, total, page, pages, setPage, pageButtons,
|
||||
pageSize, onPageSizeChange, pageSizeMax = 100,
|
||||
pageSize, onPageSizeChange, pageSizeMax = 100, allowAll = false,
|
||||
}) {
|
||||
return (
|
||||
<div className="pagination">
|
||||
|
|
@ -149,6 +154,7 @@ export function Pagination({
|
|||
value={pageSize ?? DEFAULT_PAGE_SIZE}
|
||||
onChange={onPageSizeChange}
|
||||
max={pageSizeMax}
|
||||
allowAll={allowAll}
|
||||
/>
|
||||
<span className="page-size-total">of <b>{total}</b></span>
|
||||
</>
|
||||
|
|
|
|||
Loading…
Reference in New Issue