/** * Inbox loading-state test — what the queue SHOWS while its data is in flight. * * node inbox-loading.test.mjs * * The complaint this pins down: opening the Recruitment Inbox showed skeleton * placeholders every single time, even on a revisit, and on the combined "All" * channel it showed them stacked ABOVE rows that had already arrived. The queue * is a local table that only grows when the Sync worker writes to it, so a * revisit has no business blanking itself. * * Every fetch here is gated by hand, so the assertions land on the exact frames * a fast local API would flash past: both sources pending, one source home, and * a second visit served from cache. */ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import esbuild from 'esbuild' import { JSDOM } from 'jsdom' // ---------------------------------------------------------------- environment const dom = new JSDOM('
', { url: 'http://localhost:5173/', pretendToBeVisual: true, }) globalThis.window = dom.window globalThis.document = dom.window.document Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true }) globalThis.HTMLElement = dom.window.HTMLElement globalThis.Element = dom.window.Element globalThis.Node = dom.window.Node globalThis.getComputedStyle = dom.window.getComputedStyle globalThis.localStorage = dom.window.localStorage globalThis.DOMParser = dom.window.DOMParser globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0) globalThis.cancelAnimationFrame = clearTimeout globalThis.IS_REACT_ACT_ENVIRONMENT = true class RO { observe() {} unobserve() {} disconnect() {} } class MO { observe() {} disconnect() {} takeRecords() { return [] } } globalThis.ResizeObserver = RO globalThis.MutationObserver = MO dom.window.ResizeObserver = RO dom.window.MutationObserver = MO dom.window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, }) dom.window.HTMLCanvasElement.prototype.getContext = () => new Proxy({}, { get: (_, k) => (k === 'canvas' ? {} : () => {}) }) const MODULES = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments', 'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', 'talent', 'requisitions'] const ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure'] const permissions = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`)) dom.window.localStorage.setItem('tf-auth', JSON.stringify({ access_token: 'test', refresh_token: 'test', expires_in: 1800, expires_at: Date.now() + 1800_000, data: { id: 1, name: 'Test User', email: 't@example.com', role_name: 'system_administrator', permissions }, })) // ---------------------------------------------------------------- fixtures // resume_status is the pipeline's verdict on the attachment, collapsed by // _RESUME_STATUS in backend/inbox/serializers.py. One healthy row and one whose // PDF yielded no text, because the list is supposed to tell those apart. const EMAIL_ROWS = [ { id: '11111111-1111-1111-1111-111111111111', name: 'Ada Lovelace', email: 'ada@example.com', position: 'Backend Engineer', source: 'careers-rozee@example.com', received: '2026-09-02T10:00:00Z', unread: true, processing: 'Unread', resume_status: 'Parsed', }, { id: '22222222-2222-2222-2222-222222222222', name: 'Grace Hopper', email: 'grace@example.com', position: 'Platform Engineer', source: 'careers-rozee@example.com', received: '2026-09-01T10:00:00Z', unread: false, processing: 'Read', resume_status: 'Failed', }, ] /** Matches DEFAULT_FORM_SHEET in Inbox.jsx, so the sheet picker resolves. */ const FORM_SHEET = 'Form Responses - Candidate Database Sheet 2026' const FORM_ROWS = [ { id: '33333333-3333-3333-3333-333333333333', name: 'Katherine Johnson', candidate_email: 'kj@example.com', position_applied_for: 'Data Analyst', source_of_application: 'LinkedIn', entry_date: '2026-09-03T00:00:00Z', entry_time: '09:15', processing_state: 'unread', }, ] /** * One gate per source. `open()` releases every request parked on it, and every * request that arrives afterwards resolves straight away — which is what makes * "email home, sheet still travelling" an assertable frame rather than a race. */ function gate(payload) { const waiting = [] let open = false return { calls: 0, payload, take() { this.calls += 1 if (open) return Promise.resolve(this.payload) return new Promise((resolve) => waiting.push(() => resolve(this.payload))) }, release() { open = true waiting.splice(0).forEach((fn) => fn()) }, close() { open = false }, } } const emailGate = gate({ data: EMAIL_ROWS, total: EMAIL_ROWS.length, status_code: 200 }) const formGate = gate({ data: FORM_ROWS, total: FORM_ROWS.length, status_code: 200 }) const COUNTS = { all: 3, unread: 1, processed: 0, rejected: 0, duplicates: 0 } /** Every URL the app asked for, so a test can assert on query params. */ const REQUESTS = [] const requestsMatching = (fragment) => REQUESTS.filter((u) => u.includes(fragment)) globalThis.fetch = async (input) => { const url = String(input?.url ?? input) REQUESTS.push(url) let body if (url.includes('/inbox/all-applications/count')) body = { total: EMAIL_ROWS.length, status_code: 200 } else if (url.includes('/inbox/all-applications')) body = await emailGate.take() else if (url.includes('/inbox/counts')) body = { data: COUNTS, status_code: 200 } // '/counts' BEFORE '/count': the shorter path is a substring of the longer // one, so testing it first swallowed every counts request and handed the tab // badges a payload of the wrong shape. else if (url.includes('/sheet/form-data/counts')) body = { data: { all: FORM_ROWS.length }, status_code: 200 } else if (url.includes('/sheet/form-data/count')) body = { total: FORM_ROWS.length, status_code: 200 } else if (url.includes('/sheet/form-data/sheets')) body = { data: { sheets: [FORM_SHEET] }, status_code: 200 } else if (url.includes('/sheet/form-data/fetch')) body = await formGate.take() else body = { data: [], status_code: 200 } return { ok: true, status: 200, statusText: 'OK', text: async () => JSON.stringify(body), } } // ---------------------------------------------------------------- bundle const outDir = mkdtempSync(join(tmpdir(), 'tf-inbox-')) const outFile = join(outDir, 'entry.mjs') await esbuild.build({ entryPoints: ['src/__smoke__/entry.jsx'], outfile: outFile, bundle: true, format: 'esm', platform: 'node', target: 'node20', jsx: 'automatic', loader: { '.js': 'jsx', '.jsx': 'jsx' }, logLevel: 'error', define: { 'process.env.NODE_ENV': '"development"', 'import.meta.env': JSON.stringify({ DEV: true, PROD: false, MODE: 'development', VITE_API_BASE: '' }), }, }) // ---------------------------------------------------------------- run let failed = 0 function check(name, condition, detail) { if (condition) { console.log(`ok ${name}`) if (detail) console.log(` ${detail}`) } else { console.log(`FAIL ${name}`) if (detail) console.log(` ${detail}`) failed++ } } const hasSkeleton = (html) => html.includes('skeleton-row') const hasRefreshBar = (html) => html.includes('inbox-refresh-bar') const rowCount = (html) => (html.match(/class="inbox-item/g) || []).length /** The queue row for one candidate, as a live element — badges and all. */ function rowFor(root, name) { for (const el of root.querySelectorAll('.inbox-item')) { if ((el.querySelector('.ii-name')?.textContent || '').includes(name)) return el } return null } const badgesOn = (el) => [...el.querySelectorAll('.badge')].map((b) => b.textContent.trim()) /** Only the parse-state labels; the read-state and processing badges are noise here. */ const RESUME_LABELS = ['Parsed', 'Parsing', 'Failed', 'Pending'] const resumeBadgesOn = (el) => badgesOn(el).filter((t) => RESUME_LABELS.includes(t)) try { const mod = await import(pathToFileURL(outFile).href) mod.boot() const container = dom.window.document.createElement('div') dom.window.document.body.appendChild(container) // ---- frame 1: cold open, neither source home ---------------------------- const view = await mod.mountRoute('/inbox', container) check( 'cold open with nothing cached shows placeholders', hasSkeleton(view.html()) && rowCount(view.html()) === 0, `skeleton=${hasSkeleton(view.html())} rows=${rowCount(view.html())}`, ) const allFormFetch = requestsMatching('/sheet/form-data/fetch') const allEmailFetch = requestsMatching('/inbox/all-applications').filter((u) => !u.includes('/count')) check( 'All channel sends the UI page size, not the whole form_data table', allFormFetch.some((u) => u.includes('limit=10')), allFormFetch.slice(-1)[0] || 'no form-data fetch on All', ) check( 'All channel emails send the same page size', allEmailFetch.some((u) => u.includes('top=10')), allEmailFetch.slice(-1)[0] || 'no email fetch on All', ) // ---- frame 2: email home, sheet still travelling ------------------------ // THE REGRESSION: this frame used to render six placeholders on top of two // real rows, because the All channel ORs the two pending flags together. emailGate.release() await view.settle(60) const partial = view.html() check( 'THE REGRESSION: rows that arrived are never buried under placeholders', !hasSkeleton(partial) && rowCount(partial) === EMAIL_ROWS.length, `skeleton=${hasSkeleton(partial)} rows=${rowCount(partial)}`, ) check( 'the half still in flight is named, not mimed', view.text().includes('Still loading Sheet Form applications'), ) check( 'a refetch over live rows is a hairline bar', hasRefreshBar(partial), ) // ---- frame 3: both home ------------------------------------------------- formGate.release() await view.settle(60) const settled = view.html() check( 'both sources merge into one list', rowCount(settled) === EMAIL_ROWS.length + FORM_ROWS.length, `rows=${rowCount(settled)}`, ) check( 'no placeholders and no progress bar once the queue is settled', !hasSkeleton(settled) && !hasRefreshBar(settled), ) check( 'the list says how old it is', view.text().includes('Updated just now'), ) // ---- parse state on the row --------------------------------------------- // A CV whose PDF yielded no text is never matched to a job and never scored. // Before this, the list rendered it identically to a healthy one and the only // way to find out was to click it. const failedRow = rowFor(container, 'Grace Hopper') const parsedRow = rowFor(container, 'Ada Lovelace') const formRow = rowFor(container, 'Katherine Johnson') check( 'a CV that could not be read is labelled on the row', failedRow && resumeBadgesOn(failedRow).includes('Failed'), `badges=${failedRow ? JSON.stringify(badgesOn(failedRow)) : 'row not found'}`, ) check( 'the label carries a plain-language tooltip', Boolean(failedRow?.querySelector('.badge.b-red')?.getAttribute('title')), `title=${JSON.stringify(failedRow?.querySelector('.badge.b-red')?.getAttribute('title') || '')}`, ) check( 'a healthy CV gets no badge, so the label stays a signal', parsedRow && resumeBadgesOn(parsedRow).length === 0, `badges=${parsedRow ? JSON.stringify(badgesOn(parsedRow)) : 'row not found'}`, ) // Weaker than the three above by nature: mapFormRow sets no resumeStatus, so // this passes even with the kind guard removed. It is a tripwire for the day // a form row gains such a field, not proof that the guard is doing work. check( 'Sheet Form rows are never labelled — they have no attachment to parse', formRow && resumeBadgesOn(formRow).length === 0, `badges=${formRow ? JSON.stringify(badgesOn(formRow)) : 'row not found'}`, ) const firstVisitEmailCalls = emailGate.calls await view.unmount() // ---- frame 4: leave, come back ----------------------------------------- // The headline fix. Same query keys, inside staleTime: React Query serves // the cache, so the first painted frame already has rows and no request goes // out. This is the assertion that fails if LIST_CACHE is ever removed. const second = dom.window.document.createElement('div') dom.window.document.body.appendChild(second) const revisit = await mod.mountRoute('/inbox', second) const firstFrame = revisit.html() check( 'THE FIX: re-opening the Inbox paints rows immediately, no placeholders', !hasSkeleton(firstFrame) && rowCount(firstFrame) === EMAIL_ROWS.length + FORM_ROWS.length, `skeleton=${hasSkeleton(firstFrame)} rows=${rowCount(firstFrame)}`, ) await revisit.settle(60) check( 're-opening inside staleTime issues no new list request', emailGate.calls === firstVisitEmailCalls, `calls before=${firstVisitEmailCalls} after=${emailGate.calls}`, ) await revisit.unmount() // ---- Sheet Forms link filters ------------------------------------------- // A Google Form pile has two questions worth asking of it in bulk: who gave // us a LinkedIn, and who gave us a CV. Both answers were already in the // database and neither was reachable from the screen. const third = dom.window.document.createElement('div') dom.window.document.body.appendChild(third) const forms = await mod.mountRoute('/inbox', third) check( 'no filter panel on the Email channel', !forms.findByText('button', 'Filters'), 'the link fields are sheet-only; email rows carry different columns', ) REQUESTS.length = 0 await forms.click(forms.findByText('.pill-tab', 'Sheet Forms')) await forms.settle(60) // Captured BEFORE any filter is chosen. This is what makes the "clearing // removes the params" claim further down mean something: an inactive filter // has to be absent from the URL, not present and empty. const unfiltered = requestsMatching('/sheet/form-data/fetch') check( 'an unset filter is absent from the request, not sent empty', unfiltered.length > 0 && unfiltered.every((u) => !u.includes('has_linkedin') && !u.includes('has_resume')), unfiltered.slice(-1)[0] || 'no list request went out at all', ) const toggle = forms.findByText('button', 'Filters') check('the Sheet Forms channel offers a filter toggle', Boolean(toggle)) await forms.click(toggle) const linkedinSelect = forms.find('#inbox-f-linkedin') const resumeSelect = forms.find('#inbox-f-resume') check( 'opening it reveals both link filters', Boolean(linkedinSelect) && Boolean(resumeSelect), ) check( 'the LinkedIn filter admits what it can actually prove', forms.text().includes('not a verified profile'), ) REQUESTS.length = 0 await forms.selectOption(linkedinSelect, 'yes') await forms.settle(80) const listHits = requestsMatching('/sheet/form-data/fetch') const countHits = requestsMatching('/sheet/form-data/counts') check( 'choosing a filter sends it to the list endpoint', listHits.some((u) => u.includes('has_linkedin=true')), listHits[listHits.length - 1] || 'no list request went out', ) check( 'THE DESYNC GUARD: the tab badges are recounted with the same filter', countHits.some((u) => u.includes('has_linkedin=true')), countHits[countHits.length - 1] || 'no counts request went out', ) // 'no' has to survive as a real filter. A checkbox would collapse it into // "unset", and chasing the rows MISSING a link is half the point. REQUESTS.length = 0 await forms.selectOption(resumeSelect, 'no') await forms.settle(80) check( 'a negative filter reaches the wire as false, not as omitted', requestsMatching('/sheet/form-data/fetch').some((u) => u.includes('has_resume=false')), requestsMatching('/sheet/form-data/fetch').slice(-1)[0] || 'no request', ) check( 'the toggle reports how many filters are hiding under it', (forms.findByText('button', 'Filters')?.textContent || '').includes('(2)'), `toggle reads: ${forms.findByText('button', 'Filters')?.textContent?.trim()}`, ) await forms.click(forms.findByText('button', 'Clear')) await forms.settle(80) const clearedToggle = forms.findByText('button', 'Filters') check( 'clearing resets both selects and the toggle count', !(clearedToggle?.textContent || '').includes('(') && forms.find('#inbox-f-linkedin')?.value === '' && forms.find('#inbox-f-resume')?.value === '', `toggle reads: ${clearedToggle?.textContent?.trim()}`, ) await forms.unmount() } finally { rmSync(outDir, { recursive: true, force: true }) } console.log(failed ? `\n${failed} inbox loading check(s) FAILED` : '\nAll inbox loading checks passed') process.exit(failed ? 1 : 0)