- Initialize simulated_tier session state for demo mode after resolve_session/init_db
- Render demo toolbar before pg.run() when IS_DEMO is set
- Render ui_switcher banner before pg.run() for non-demo paid-tier users (guarded with try/except)
- Sync ui_preference cookie after pg.run() (guarded with try/except)
- All imports are local (inside if-blocks) to avoid Streamlit circular import issues
- Add explanatory comments to all 5 bare except Exception blocks clarifying that UI components must not crash the app
- Refactor sync_ui_cookie() to load UserProfile once instead of up to 3 times in normal path
- Store profile reference and reuse it in tier downgrade protection block
- Replace importlib.reload() pattern in tests with unittest.mock.patch for _DEMO_MODE
- Improves test isolation and eliminates module state contamination across test runs
- All 5 tests pass (100%)
- Replace fragile reload pattern with unittest.mock.patch('app.wizard.tiers._DEMO_MODE', ...)
- Eliminates parallel test run failures (pytest-xdist) and improves test isolation
- All 4 demo_tier tests now use context managers for clean setup/teardown
- Add explanatory comment to _DEMO_MODE definition about immutability and env-based init
get_page_errors() was switched to text_content() to capture errors in
CSS-hidden elements (collapsed Streamlit expanders). Two unit test mocks
still stubbed inner_text() — causing CI failures because MagicMock()
returned a non-string from text_content(), breaking the "boom" in message
content assertion.
E2E harness fixes to get all three modes (demo/cloud/local) passing:
- conftest.py: use ctx.add_cookies() for cloud auth instead of
ctx.route() or set_extra_http_headers(). Playwright's route() only
intercepts HTTP; set_extra_http_headers() explicitly excludes
WebSocket handshakes. Streamlit reads st.context.headers from the
WebSocket upgrade, so cookies are the only vehicle that reaches it
without a reverse proxy.
- cloud_session.py: fall back to Cookie header when X-CF-Session is
absent — supports direct access (E2E tests, dev without Caddy).
In production Caddy sets X-CF-Session; in tests the cf_session cookie
is set on the browser context and arrives in the Cookie header.
- modes/cloud.py: add /peregrine base URL path (STREAMLIT_SERVER_BASE_URL_PATH=peregrine)
- modes/local.py: correct port from 8502 → 8501 and add /peregrine path
All three modes now pass smoke + interaction tests clean.
- Add tests/e2e/test_smoke.py: page-load error check for all pages
- Add tests/e2e/test_interactions.py: click every interactable, diff
errors, XFAIL expected demo failures, flag regressions as XPASS
- Fix conftest get_page_errors() to use text_content() instead of
inner_text() so errors inside collapsed expanders are captured with
their actual message text (inner_text respects CSS display:none)
- Fix tests/e2e/modes/demo.py base_url to include /peregrine path prefix
(STREAMLIT_SERVER_BASE_URL_PATH=peregrine set in demo container)
App fixes surfaced by the harness:
- task_runner.py: add DEMO_MODE guard for discovery task — previously
crashed with FileNotFoundError on search_profiles.yaml before any
demo guard could fire; now returns friendly error immediately
- 6_Interview_Prep.py: stop auto-triggering LLM session on page load
in demo mode; show "AI features disabled" info instead, preventing
a silent st.error() inside the collapsed Practice Q&A expander
Both smoke and interaction tests now pass clean against demo mode.
BasePage provides navigation, error capture, and interactable discovery
with fnmatch-based expected_failure matching. SettingsPage extends it
with tab-aware discovery. All conftest imports are deferred to method
bodies so the module loads without a live browser fixture.
Add pytest-playwright and pytest-json-report to requirements.txt; create
tests/e2e/ skeleton (modes/, pages/, results/) with __init__.py files and
.gitkeep; add results subdirs to .gitignore.
Adds `e2e` subcommand to manage.sh supporting headless (default) and
headed (E2E_HEADLESS=false via xvfb-run) Playwright test runs with
per-mode JSON report output under tests/e2e/results/<mode>/.
- Settings: add st.rerun() after storing _kw_suggestions so chips appear
immediately without requiring a tab switch (#18)
- Setup wizard step 4: prefill name/email/phone from parsed resume when
identity fields are blank; saved values take precedence on re-visit (#17)
- Home dashboard: sync section shows provider name when Notion is connected,
or 'Set up a sync integration' with a settings link when not configured (#16)
Implements idempotent calendar push for Apple Calendar (CalDAV) and
Google Calendar from the Interviews kanban.
- db: add calendar_event_id column (migration) + set_calendar_event_id helper
- integrations/apple_calendar: create_event / update_event via caldav + icalendar
- integrations/google_calendar: create_event / update_event via google-api-python-client;
test() now makes a real API call instead of checking file existence
- scripts/calendar_push: orchestrates push/update, builds event title from stage +
job title + company, attaches job URL and company brief to description,
defaults to noon UTC / 1hr duration
- app/pages/5_Interviews: "Add to Calendar" / "Update Calendar" button shown
when interview date is set and a calendar integration is configured
- environment.yml: pin caldav, icalendar, google-api-python-client, google-auth
- tests/test_calendar_push: 9 tests covering create, update, error handling,
event timing, idempotency, and missing job/date guards
New cloud users got a "resume_keywords.yaml not found" warning in
Settings → Skills & Keywords because the file was never created during
account provisioning. resolve_session() now writes an empty scaffold
(skills/domains/keywords: []) to the user's config dir on first visit
if the file doesn't exist, consistent with how config/ and data/ dirs
are already created. Never overwrites an existing file.
- cloud_session.py: add _ensure_provisioned() called in resolve_session() so
new Google OAuth signups get a free Heimdall key created on first page load;
previously resolve returned "free" tier but no key was ever written to
Heimdall, leaving users in an untracked state
- Home.py: replace conda run invocation in "Score All Unscored Jobs" with
sys.executable so the button works inside Docker where conda is not present
When _profile is None the fallback pattern \w+ only matched the first
word of a two-word sign-off (e.g. 'Alex' from 'Alex Rivera'), silently
dropping the last name. Switch fallback to \w+(?:\s+\w+)? so a full
first+last sign-off is preserved in no-config environments (CI, first run).
When source == "jobgether", build_prompt() injects a recruiter context
note directing the LLM to address the Jobgether recruiter using
"Your client [at {company}] will appreciate..." framing rather than
addressing the employer directly. generate() and task_runner both
thread the is_jobgether flag through automatically.
Replaces the spawn-per-task model for LLM task types with scheduler
routing: cover_letter, company_research, and wizard_generate are now
enqueued via the TaskScheduler singleton for VRAM-aware batching.
Non-LLM tasks (discovery, email_sync, etc.) continue to spawn daemon
threads directly. Adds autouse clean_scheduler fixture to
test_task_runner.py to prevent singleton cross-test contamination.