fix(ci): restore green CI — libsqlcipher-dev, prep/survey test drift
Some checks failed
CI / Backend (Python) (push) Failing after 2m25s
CI / Frontend (Vue) (push) Successful in 24s
Mirror / mirror (push) Failing after 8s
Release / release (push) Failing after 4s

Backend: add apt-get install libsqlcipher-dev before pip install so
pysqlcipher3 builds in the runner image.

Frontend: prep.test.ts was missing a qa mock (fetchFor now calls 5
endpoints in parallel; tests only mocked 4 — 5th returned undefined,
threw in catch, research.value never set). survey.test.ts: analyze()
was refactored from sync-result to async-task+poll; update test to
mock POST then poll completion.

Also remove Classic UI (Streamlit) button from AppNav — Streamlit is
deprecated and the button caused an unrecoverable redirect loop.
This commit is contained in:
pyr0ball 2026-05-17 21:50:35 -07:00
parent 0d6ddd35cf
commit b44a7975bc
4 changed files with 19 additions and 45 deletions

View file

@ -23,6 +23,9 @@ jobs:
python-version: '3.12' python-version: '3.12'
cache: pip cache: pip
- name: Install system dependencies
run: sudo apt-get update -q && sudo apt-get install -y libsqlcipher-dev
- name: Install dependencies - name: Install dependencies
run: pip install -r requirements.txt run: pip install -r requirements.txt

View file

@ -59,9 +59,6 @@
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" /> <Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
<span class="sidebar__label">Settings</span> <span class="sidebar__label">Settings</span>
</RouterLink> </RouterLink>
<button class="sidebar__classic-btn" @click="switchToClassic" title="Switch to Classic (Streamlit) UI">
Classic
</button>
</div> </div>
</nav> </nav>
@ -134,23 +131,6 @@ function exitHackerMode() {
restoreTheme() restoreTheme()
} }
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
async function switchToClassic() {
// Persist preference via API so Streamlit reads streamlit from user.yaml
// and won't re-set the cookie back to vue (avoids the ?prgn_switch rerun cycle)
try {
await fetch(_apiBase + '/api/settings/ui-preference', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preference: 'streamlit' }),
})
} catch { /* non-fatal — cookie below is enough for immediate redirect */ }
document.cookie = 'prgn_ui=streamlit; path=/; SameSite=Lax'
// Navigate to root (no query params) Caddy routes to Streamlit based on cookie
window.location.href = window.location.origin + '/'
}
const navLinks = computed(() => [ const navLinks = computed(() => [
{ to: '/', icon: HomeIcon, label: 'Home' }, { to: '/', icon: HomeIcon, label: 'Home' },
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' }, { to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
@ -321,29 +301,6 @@ const mobileLinks = [
margin: 0; margin: 0;
} }
.sidebar__classic-btn {
display: flex;
align-items: center;
width: 100%;
padding: var(--space-2) var(--space-3);
margin-top: var(--space-1);
background: none;
border: none;
border-radius: var(--radius-md);
color: var(--color-text-muted);
font-size: var(--text-xs);
font-weight: 500;
cursor: pointer;
opacity: 0.6;
transition: opacity 150ms, background 150ms;
white-space: nowrap;
}
.sidebar__classic-btn:hover {
opacity: 1;
background: var(--color-surface-alt);
}
/* ── Theme picker ───────────────────────────────────── */ /* ── Theme picker ───────────────────────────────────── */
.sidebar__theme { .sidebar__theme {
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);

View file

@ -27,6 +27,7 @@ describe('usePrepStore', () => {
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: '2026-03-20T12:00:00' }, error: null }) // research generated_at: '2026-03-20T12:00:00' }, error: null }) // research
.mockResolvedValueOnce({ data: [], error: null }) // contacts .mockResolvedValueOnce({ data: [], error: null }) // contacts
.mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task .mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null, .mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
description: 'Build things.', cover_letter: null, match_score: 80, description: 'Build things.', cover_letter: null, match_score: 80,
@ -50,6 +51,7 @@ describe('usePrepStore', () => {
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: null }, error: null }) generated_at: null }, error: null })
.mockResolvedValueOnce({ data: [], error: null }) .mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) .mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Old Job', company: 'OldCo', url: null, .mockResolvedValueOnce({ data: { id: 1, title: 'Old Job', company: 'OldCo', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null }) description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
@ -62,6 +64,7 @@ describe('usePrepStore', () => {
mockApiFetch mockApiFetch
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 404, detail: '' } }) // 404 → null .mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 404, detail: '' } }) // 404 → null
.mockResolvedValueOnce({ data: [], error: null }) .mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) .mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 2, title: 'New Job', company: 'NewCo', url: null, .mockResolvedValueOnce({ data: { id: 2, title: 'New Job', company: 'NewCo', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null }) description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
@ -102,6 +105,7 @@ describe('usePrepStore', () => {
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: null }, error: null }) generated_at: null }, error: null })
.mockResolvedValueOnce({ data: [], error: null }) .mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) .mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null, .mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null }) description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
@ -112,11 +116,12 @@ describe('usePrepStore', () => {
// Mock first poll → completed // Mock first poll → completed
mockApiFetch mockApiFetch
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null }) .mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
// re-fetch on completed: research, contacts, task, fullJob // re-fetch on completed: research, contacts, qa, task, fullJob
.mockResolvedValueOnce({ data: { company_brief: 'Updated!', ceo_brief: null, talking_points: null, .mockResolvedValueOnce({ data: { company_brief: 'Updated!', ceo_brief: null, talking_points: null,
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: '2026-03-20T13:00:00' }, error: null }) generated_at: '2026-03-20T13:00:00' }, error: null })
.mockResolvedValueOnce({ data: [], error: null }) .mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null }) .mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null, .mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
description: 'Now with content', cover_letter: null, match_score: null, keyword_gaps: null }, error: null }) description: 'Now with content', cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
@ -134,6 +139,7 @@ describe('usePrepStore', () => {
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: null }, error: null }) generated_at: null }, error: null })
.mockResolvedValueOnce({ data: [], error: null }) .mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) .mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null, .mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null }) description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
@ -162,6 +168,7 @@ describe('usePrepStore', () => {
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: '2026-03-20T12:00:00' }, error: null }) // research OK generated_at: '2026-03-20T12:00:00' }, error: null }) // research OK
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 500, detail: 'DB error' } }) // contacts fail .mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 500, detail: 'DB error' } }) // contacts fail
.mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task OK .mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task OK
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null, .mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
description: 'Build things.', cover_letter: null, match_score: 80, description: 'Build things.', cover_letter: null, match_score: 80,

View file

@ -54,14 +54,20 @@ describe('useSurveyStore', () => {
}) })
it('analyze stores result including mode and rawInput', async () => { it('analyze stores result including mode and rawInput', async () => {
vi.useFakeTimers()
const mockApiFetch = vi.mocked(useApiFetch) const mockApiFetch = vi.mocked(useApiFetch)
// POST → task accepted
mockApiFetch.mockResolvedValueOnce({ data: { task_id: 7, is_new: true }, error: null })
// Poll → completed with result
mockApiFetch.mockResolvedValueOnce({ mockApiFetch.mockResolvedValueOnce({
data: { output: '1. B — reason', source: 'text_paste' }, data: { status: 'completed', stage: null, message: null,
result: { output: '1. B — reason', source: 'text_paste' } },
error: null, error: null,
}) })
const store = useSurveyStore() const store = useSurveyStore()
await store.analyze(1, { text: 'Q1: test', mode: 'quick' }) await store.analyze(1, { text: 'Q1: test', mode: 'quick' })
await vi.advanceTimersByTimeAsync(3000)
expect(store.analysis).not.toBeNull() expect(store.analysis).not.toBeNull()
expect(store.analysis!.output).toBe('1. B — reason') expect(store.analysis!.output).toBe('1. B — reason')
@ -69,6 +75,7 @@ describe('useSurveyStore', () => {
expect(store.analysis!.mode).toBe('quick') expect(store.analysis!.mode).toBe('quick')
expect(store.analysis!.rawInput).toBe('Q1: test') expect(store.analysis!.rawInput).toBe('Q1: test')
expect(store.loading).toBe(false) expect(store.loading).toBe(false)
vi.useRealTimers()
}) })
it('analyze sets error on failure', async () => { it('analyze sets error on failure', async () => {