Files
cali_scraper/QA_CHECKLIST.md
T
2026-09-06 10:18:26 -07:00

15 KiB
Raw Blame History

QA Sign-Off Checklist — California Legislature Daily File Scraper

Task: t_8e5a3821 · Lane: Software Quality Assurance Status: DRAFT — awaiting sign-off before first production cron run Scope: ingestion (scraper.ingest) + parsing (scraper.parsers) pipeline, cache, HTTP/retry layer, and the python -m scraper CLI.

This checklist is the gate the acceptance criteria require ("the QA checklist is reviewed and signed off before the first production cron run"). Every item is either (a) verified by the automated suite in tests/, or (b) a known failure mode with a mitigation. Do not start the production cron until every "Pre-cron" box is ticked and a reviewer has signed the footer.


0. How to use this checklist

# Full deterministic suite (no network) + coverage gate on the parser core:
python3 -m pytest

# Emit the coverage report the acceptance criteria require (>=85% on parser core):
python3 -m pytest --cov

# Opt in to the one live smoke test (skipped by default so CI never depends on
# the external servers being up):
CALI_SCRAPER_SMOKE=1 python3 -m pytest tests/test_live_smoke.py -m smoke

Green criteria (from the task):

  • pytest passes green on the fixture suite (all of tests/, no skips).
  • Coverage report meets the >= 85 % threshold on scraper.parsers (+ scraper.bill_id); --cov exits 0.
  • QA checklist reviewed and signed off (this document) before cron.

1. Automated gate (must be green)

# Check Verified by Passes?
1.1 Parser codec round-trips AB/SB/ACR/SCR/HR/SJR/ABR + rejects junk tests/test_bill_id.py (21) ☐
1.2 Schema/JSONL: field set, content-hash id, null-stripping, round-trip tests/test_schema.py (17) ☐
1.3 Parsers: 3 dates, both chambers, floor/committee/hearing discrimination tests/test_parsers.py (35) ☐
1.4 Malformed rows skip + log warning, batch survives; UTF-16/encoding edge tests/test_parsers.py ☐
1.5 Ingest→parse end-to-end → valid JSONL schema; request-log recorded tests/test_integration.py ☐
1.6 Failure modes: expected-404, antibot-403, transient-retry×3 tests/test_integration.py ☐
1.7 Cache hit serves from disk with zero network; corrupt cache = miss tests/test_integration.py ☐
1.8 Live smoke (opt-in); skipped by default tests/test_live_smoke.py ☐ (skip)
1.9 Coverage ≥ 85 % on scraper.parsers / scraper.bill_id pytest --cov ☐

Known, deliberately-pinned behaviour (not bugs — see §4):

  • normalise_date("2026-08-31") returns None (the -NNN display-suffix stripper runs before ISO); the site only ever emits MM/DD/YY, so this is safe in practice. A fix must surface as a changed assertion, not a silent regression.

2. Failure modes, detection & mitigation

Each row maps a known failure mode to how the system detects it, how the automated suite proves the handling, and the operator mitigation. These are the "site redesign, rate-limiting, encoding shifts" the task calls out.

2.1 Site redesign / structural HTML change · highest risk

Aspect Detail
Symptom A CSS class or data-sort attribute the parser relies on (e.g. tr.floor-bill-details, td.floor_bill-measure a) is renamed/removed.
Detection (automatic) tests/test_parsers.py::TestArchivedSample parses the archived samples/assembly_dailyfile_latest.html and asserts ≥150 bill rows / ≥4 hearing rows. A structural break drops the count → red gate. Also: test_happy_path_ingest_then_parse_then_jsonl asserts AB 28 and SB 795 survive the full pipeline.
Runtime symptom Parse yields 0 (or a few) records where the prior run yielded ~150.
Mitigation Roll back to last good cache/raw/<date> (never deleted). Re-scrape selectors from the live page, update DAILY_FILE_PARSE_SPEC.md §3 and the parser selectors, re-record a fresh sample in samples/, re-run the gate.
Watch A sudden count drop is the #1 leading indicator — pair the cron with a "record-count anomaly" alert (count vs. 7-day median).

2.2 Rate-limiting / 429

Aspect Detail
Symptom httpx 429 (or 5xx) — the source throttles on volume.
Detection (automatic) tests/test_integration.py::test_transient_retries_three_times proves the retry loop makes 3 attempts with exponential back-off [1.0, 2.0]s and then raises TransientError.
Runtime handling config.yaml → http.retry_status drives this; 429 should be added to that list if the source uses 429 (currently [500,502,503,504]). Backoff is capped at backoff_cap_seconds.
Mitigation Lower cron frequency; add 429 to retry_status with a longer cap; if sustained, route via the blocked-page-recovery skill. Alert is fired per attempt via the request log.
Watch A rising share of 5xx_retrying/transient_gave_up outcomes in cache/request_log.jsonl.

2.3 Antibot / 403 (Senate PDF path) · documented, expected

Aspect Detail
Symptom senate.ca.gov/dailyfile/publications/download/... returns a 67-byte 403 antibot challenge on programmatic fetch.
Detection (automatic) test_antibot_403_is_flagged proves a 403 → status="antibot", all_ok=False, alert fired, no retry-storm.
Runtime handling 403 is classified antibot_status=[403]; it is NOT retried (a retry storm just wastes the throttle) — it alerts and stops. Mitigation is the blocked-page-recovery skill (headless browser + session pre-warm).
Mitigation / workaround Until antibot is solved, Senate data is unavailable; Assembly-only output is valid. Optionally enrich Senate floor/committee items from the leginfo billResultsClient/billHistoryClient alt (spec §5b).
Watch antibot appears in request-log outcomes only on the Senate source.

2.4 "No publication today" / expected 404 · expected, not a failure

Aspect Detail
Symptom Senate PDF path 404s on non-legislative days (weekends, recess).
Detection (automatic) test_expected_404_is_not_an_error proves 404 on a missing_is_expected_404 source → status="not_found" with "expected" in the note, and all_ok is not polluted by it.
Runtime handling config.yaml → senate_daily_file.missing_is_expected_404: true makes a 404 a clean exit-0 outcome; cron mail fires only on real failures (antibot/transient/unexpected-404).
Watch A 404 on the Assembly source (which has no missing_is_expected_404) is a warning-level alert, not a clean no-file.

2.5 Encoding shifts / bad charset

Aspect Detail
Symptom A page changes its declared charset (UTF-8 → Windows-1252, adds a BOM, etc.) and produces mojibake or crashes a naive decode.
Detection (automatic) tests/test_parsers.py::test_utf16_edge_case_does_not_crash writes a UTF-16/BOM file and asserts the parser survives (logs an encoding warning, returns []) rather than aborting the batch.
Runtime handling scraper.parsers._read_text sniffs the charset from <meta>/BOM and falls back to utf-8/replace, logging a warning on replacement chars.
Mitigation On persistent mojibake the field values are corrupted but the pipeline does not crash; inspect the warning in logs and re-fetch.
Watch replacement chars detected warnings in the request/app log.

2.6 Corrupt / truncated cache

Aspect Detail
Symptom A crash mid-download leaves a truncated raw file, or a meta sha256 mismatches the bytes.
Detection (automatic) tests/test_integration.py::test_cache_corruption_is_treated_as_a_miss proves a sha mismatch is treated as a cache miss, so a good fetch re-runs instead of serving poison.
Runtime handling RawCache.put is atomic (tmp + os.replace); get verifies sha256 before serving.
Watch Repeated misses on a date that was previously cached suggests disk corruption — inspect cache/raw.

2.7 Malformed rows / bad data within an otherwise-valid page

Aspect Detail
Symptom A floor row has no bill link, a non-numeric item cell, or a hearing row with neither committee nor date.
Detection (automatic) test_malformed_rows_skipped_not_aborted proves bad rows are skipped per-row with a WARNING and the batch (good + recoverable rows) still completes.
Runtime handling scraper.parsers skips+warns, never aborts; bad rows are logged for triage.
Watch A spike in per-row WARNING lines indicates an upstream data-quality regression.

2.8 Dependency / import availability

Aspect Detail
Symptom httpx/PyYAML/beautifulsoup4/lxml missing in a fresh cron env.
Detection Import guards in scraper/config.py and scraper/http_client.py raise a SystemExit with a clear "install with pip install …" message (not a bare ModuleNotFoundError).
Mitigation requirements.txt pins the working versions (Python 3.9). The cron wrapper should pip install -r requirements.txt (or run in a venv with them pre-installed) before each run.
Watch A run that exits 2 with the "… is required" message means the cron env is missing a dep.

2.9 P2 finding: retry-storm on non-retryable 4xx · defect, regression-guarded

Aspect Detail
Symptom scraper/http_client.py has a catch-all return "transient" for any non-404/non-antibot HTTP status. A permanent 4xx such as 400 is retry-stormed 3× with back-off [1.0, 2.0]s — identical to a transient 5xx, even though the code's own comment says it "should not retry-storm."
Detection (automatic) tests/test_integration.py::TestRetryStormRegressionGuard::test_retry_storm_on_4xx_is_currently_3_attempts asserts the current buggy behaviour (3 attempts, sleeps == [1.0, 2.0]), keeping the suite green. The companion test_non_retryable_4xx_must_give_up_on_attempt_1 is a strict xfail that will flip to a failure when the engine is fixed, signalling "finding resolved, remove/convert this guard."
Runtime impact A 400 from the source causes 3 full fetches + 3 request-log entries where 1 would suffice. Not a data-corruption risk; a waste of the request throttle and a noise amplifier in the request log.
Fix In scraper/http_client.py, narrow the catch-all so that only transient-network faults and the explicit retry_status list are classified transient; a 4xx that is not 404/antibot/retry_status should be classified a non-retryable failure (a single-attempt terminal state), or retry_status should be expanded to exclude it.
Watch This xfail is a trip-wire; a strict-xfail going green is a signal to convert it to a plain test and re-baseline the counts.

3. Rollback & recovery procedure

The pipeline is designed so that a bad run never destroys a good one — the cache is append-only on the network side and atomic on the disk side.

3.1 Pre-cron pre-flight (run every time, before first cron)

  • .git working copy clean or on the expected branch/commit.
  • python3 -m pytest is green (all of tests/, no non-live skips failing).
  • python3 -m pytest --cov reports ≥ 85 % on scraper.parsers and exits 0 (the --cov addopts enforce this; a sub-floor run is a failure).
  • python3 -m scraper list shows both chamber sources with the URLs from config.yaml.
  • python3 -m scraper check --date <recent-legislative-day> shows the cache populated (or a first run is expected to be a miss).
  • cache/request_log.jsonl writable; alert sink configured (config.yaml).
  • This checklist is signed (footer) by a human reviewer.

3.2 Rollback on a bad run

  1. Do not delete the cache. cache/raw/<source>/<date>.<ext> is the source of truth and is append-only; a failed new fetch does not overwrite a prior good cache hit.
  2. To serve "last known good": re-ingest with the default force=False — the cache serves the last good bytes; only a --force re-fetch reaches the network.
  3. Re-parse the cached bytes with python -m scraper parse --input cache/raw/… — the parse is a pure function of the bytes, so re-parsing is safe.
  4. If the parser selectors broke (red gate, §2.1): roll back the parser commit, re-parse the previous cached bytes with the old code, and hold the new sample + selector changes until the gate is green again.
  5. If a corrupt cache is suspected (§2.6): delete only the affected cache/raw/<source>/<date>.* + its .meta.json sidecar; the next non- forced run re-fetches. (A sha-mismatch already forces a re-fetch automatically, so this is belt-and-braces.)
  6. Record the incident in cache/request_log.jsonl review and, if it changed upstream, refresh the archived sample in samples/ and the parse spec.

3.3 Recovery from each failure mode

Mode Recovery
2.1 redesign Roll back parser commit; re-derive selectors from live page; new sample; re-gate.
2.2 rate-limit Back off cron frequency; add 429 to retry_status; or blocked-page-recovery skill.
2.3 antibot (Senate) Use blocked-page-recovery skill (headless + session pre-warm); meanwhile run Assembly-only.
2.4 expected 404 No action — this is normal; confirm it's not_found + "expected".
2.5 encoding Re-fetch; confirm charset in the warning; update _read_text sniff list if a new charset appears.
2.6 corrupt cache Delete affected cache entry (+meta); re-fetch.
2.7 malformed rows Triage the logged WARNINGs; an upstream data regression, not a pipeline bug.
2.8 missing dep pip install -r requirements.txt in the cron env.
2.9 retry-storm(4xx) Engineer narrows the transient catch-all in http_client.py; re-baseline the xfail guard (§2.9).

4. Test suite map (what lives where)

File Covers Count
tests/test_bill_id.py scraper.bill_id codec: parse/normalise/origin/display/supplement + negatives 21
tests/test_schema.py scraper.schema BillAction: fields, content-hash id, JSONL round-trip 17
tests/test_parsers.py scraper.parsers: sample + 3 dates + both chambers + robustness/edge 35
tests/test_integration.py ingest→parse end-to-end JSONL schema + 404/antibot/transient/cache + P2 retry-storm guard 12
tests/test_live_smoke.py opt-in live-URL smoke (skipped unless CALI_SCRAPER_SMOKE=1) 2

Deterministic suite (no network) total: 84 passed (+ 1 strict xfail, a regression trip-wire on the §2.9 retry-storm finding). Live smoke: 2, skipped by default so CI is independent of external availability. Full run is 84 passed, 2 skipped, 1 xfailed; coverage on the parser core (scraper.parsers + scraper.bill_id) is 92.0 %, which clears the ≥ 85 % fail_under gate.


5. Sign-off

Gate: no production cron run starts until 1.1–1.9 are green and the §3.1 pre-flight list is ticked.

Role Name Date Signature
QA (author) software_quality_assurance ______ ____________
Engineer (parser lane) __________ ______ ____________
Manager / release __________ ______ ____________

Reviewer note: this checklist is a DRAFT pending human sign-off. The automated half (1.1–1.9) is enforced by pytest/pytest --cov and is not a substitute for a human ticking §3.1.