From e21243a2ecd2c7608f279c4fb8c68d1e58e751ee Mon Sep 17 00:00:00 2001 From: Taleef Date: Tue, 8 Sep 2026 17:23:35 -0400 Subject: [PATCH 1/4] fix(program): a measure's rate is its own rows, and the Maui e2e suite runs the configuration the pilot deploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The programs overview served the whole run's official evidence under each measure's name. `aggregateOfficialRun` paged `listOutcomes(runId)` unfiltered, under a comment claiming a run evaluates one measure with one engine — true of a MEASURE-scoped run, false of the ALL_PROGRAMS run the pilot actually runs, which holds a row per (subject, measure) pair. On the sandbox CMS122's card carried CMS125's initial population, CMS125's 59.2% and `ecqmId: 125FHIR`. The MeasureReport and reconciliation routes were unaffected (both refuse a multi-measure run with 422); the overview was the one unguarded caller. `listOutcomes` now takes an optional `measureId` and narrows in SQL, before the page window, in both stores — the app-side alternative would page 120,000 rows once per measure on a six-measure nightly. `runProducedOfficialEvidence` is scoped the same way, so a run that mixes an official and an authored measure (ADR-072 admits one) no longer answers for both from whichever row sorted first. The Maui Playwright job ran with no VSAC credential, so cms2/cms130/cms165/cms137 booted `official-pending` and the suite's green was about a stack nobody deploys. It now vendors the six terminology sidecars with the credential and sets WORKWELL_OFFICIAL_MEASURES to the deployed value. Because a missing sidecar degrades rather than crashes, the backend's own `runnable=` boot line is asserted — without it the job runs two measures and reports green, which is what it had been doing since 2026-09-06. The job is dispatch-only and this is a public repo, so the key is scoped to a single step that runs before the frontend and e2e dependency trees exist, and the completed expansions are not cached: an Actions cache is restorable by a fork pull request. Eleven failing specs, none a backend regression: three needed the routed stack, two asserted the case fan-out ADR-078 removed (now bounds, with the out-of-population gap named), three signed in as the quality lead for pages that are engineering-gated to ADMIN — two of those had been asserting against an AccessDenied panel — one contradicted the §6.3 patient-column contract, and one matched a PCP option inside a closed select. 43 tests to 26 with six measures covered instead of two; four workers and one shared sign-in per role replace ~40 logins. The roster's Status/PCP/Age/Sex/Page-size selects had no accessible name (Chrome computes none from the wrapping label) and now carry aria-labels, matching Panel/System/Segment. --- .github/workflows/ci.yml | 101 ++++++++++++++-- backend-ts/src/fhir/run-aggregate.ts | 25 ++-- backend-ts/src/program/measure-rate.test.ts | 29 +++++ backend-ts/src/program/measure-rate.ts | 6 +- backend-ts/src/routes/runs.ts | 2 +- backend-ts/src/stores/outcome-store.ts | 8 +- .../stores/postgres/outcome-store-postgres.ts | 6 +- .../src/stores/sqlite/outcome-store-sqlite.ts | 10 +- backend-ts/src/stores/store-contract.ts | 27 +++++ docs/JOURNAL.md | 54 +++++++++ e2e/.gitignore | 3 + e2e/global-setup.ts | 32 +++++- e2e/playwright.config.ts | 7 +- e2e/tests/maui/auth.spec.ts | 13 ++- e2e/tests/maui/case-workflow.spec.ts | 4 + e2e/tests/maui/exports.spec.ts | 13 ++- e2e/tests/maui/helpers.ts | 37 ++++++ e2e/tests/maui/jelly-beans.spec.ts | 108 +++++++++--------- e2e/tests/maui/measures.spec.ts | 21 ++-- e2e/tests/maui/pending-readiness.spec.ts | 61 +++++----- e2e/tests/maui/roster.spec.ts | 104 +++++++---------- e2e/tests/maui/runs.spec.ts | 39 ++++--- e2e/tests/maui/terminology.spec.ts | 78 ++++++------- frontend/app/(dashboard)/compliance/page.tsx | 5 + 24 files changed, 548 insertions(+), 245 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce472859..cf573dc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -615,13 +615,19 @@ jobs: timeout-minutes: 45 # The Maui project runs against a stack THIS RUNNER boots — backend on the SQLite floor with # WORKWELL_INSTANCE=maui, frontend built in patient terminology with the public-demo affordances - # off — so nothing shared is mutated and the allowlist never comes into play. It deliberately - # runs the authored cms122/cms125 (no WORKWELL_OFFICIAL_MEASURES): the official artifacts need - # the vendored terminology sidecars, which are fetched at build with a VSAC key this job does not - # have, and with no VSAC key at runtime the authored path resolves the bundled expansions and - # yields the same designed 38/7/3 distribution the tests assert. Browser e2e lives here rather - # than on a developer box because Chromium + two dev servers + a test worker exhaust a Windows - # desktop heap long before they exhaust RAM (measured 2026-09-02). + # off — so nothing shared is mutated and the allowlist never comes into play. Browser e2e lives + # here rather than on a developer box because Chromium + two dev servers + a test worker exhaust a + # Windows desktop heap long before they exhaust RAM (measured 2026-09-02). + # + # SINCE 2026-09-08 this job runs the ROUTED configuration — all six of the ACO's measures, the same + # WORKWELL_OFFICIAL_MEASURES the deployed sandbox ships (ADR-078) — by vendoring each measure's + # terminology sidecar with the VSAC credential, exactly as flip-gate.yml does. It previously ran + # authored cms122/cms125 with no VSAC key, which meant the suite's green was about a configuration + # nobody deploys: the four official-only measures sat `official-pending`, and the specs that + # enumerate them failed silently from 2026-09-06 (when #528 added them) until the next manual + # dispatch five days later. A stack that is not the one the pilot runs is not a pilot test. + env: + ROUTED_MEASURES: cms122,cms125,cms2,cms130,cms165,cms137 steps: # backend-ts consumes @mieweb/cloud + @mieweb/cli from the external/mieweb-cloud submodule. - uses: actions/checkout@v7 @@ -636,19 +642,76 @@ jobs: - name: Install backend dependencies working-directory: backend-ts run: pnpm install --frozen-lockfile + # ---- The credentialed steps run HERE, before the frontend and e2e dependency trees are on disk. + # This job is public-repo CI, and the one exposure that is not covered by "workflow_dispatch needs + # write access" is supply chain: anything in an installed node_modules can read the environment of + # a step it runs in. The VSAC key is therefore scoped to a single step (never job-level `env`), + # and that step executes with the BACKEND tree only — not Chromium, not Next.js, not the e2e tree. + # + # The official artifacts retrieve through their OWN terminology (ADR-036), completed from a + # pinned VSAC release at vendor time. Without it the four official-only measures resolve capped + # expansions, read a zero initial population, and the stack reports them `official-pending`. + - name: Cache official content + uses: actions/cache@v6 + with: + path: backend-ts/.official-content + key: official-content-${{ hashFiles('backend-ts/scripts/fetch-official-cases.ps1') }} + - name: Fetch official content (pinned commit) + working-directory: backend-ts + run: pwsh -NoProfile -File scripts/fetch-official-cases.ps1 + # The completed sidecars are deliberately NOT cached. They are VSAC value-set expansions — + # licensed content, gitignored for that reason — and an Actions cache is restorable by other + # workflow runs in this PUBLIC repo, including a fork pull request whose code we do not control. + # Re-vendoring costs about a minute; putting licensed expansions somewhere fork-authored code can + # read them is not worth a minute. + - name: Vendor each routed measure's terminology, completed from VSAC + working-directory: backend-ts + env: + WORKWELL_VSAC_API_KEY: ${{ secrets.WORKWELL_VSAC_API_KEY_VENDOR }} + run: | + set -o pipefail + if [ -z "$WORKWELL_VSAC_API_KEY" ]; then + echo "::error::No VSAC credential in this context. This job asserts the ROUTED configuration; over capped expansions every official-only measure would read a zero initial population and the suite would call that a pass." + exit 1 + fi + IFS=',' read -ra MEASURES <<< "$ROUTED_MEASURES" + for id in "${MEASURES[@]}"; do + NAME="$(pnpm exec tsx -e "import {officialMeasureName} from './src/standards/official-cases.ts'; process.stdout.write(officialMeasureName('${id}') ?? '')")" + if [ -z "$NAME" ]; then echo "::error::${id} is not an official measure id"; exit 1; fi + pnpm vendor:official --measure "$NAME" --catalog-id "$id" --strip-elm-annotations --complete-terminology + TRUNCATED=$(jq -r '.terminology.truncated | length' "measures/official/${id}/manifest.json") + if [ "$TRUNCATED" != "0" ]; then + echo "::error::${id}'s vendored terminology still reports ${TRUNCATED} TRUNCATED expansion(s) — refusing to run the suite against capped expansions." + exit 1 + fi + done + # Only the gitignored sidecar may be new; the committed artifact must still match its pin. + - name: The committed artifacts are reproducible from their pins + working-directory: backend-ts + run: git diff --exit-code measures/official + # ---- End of the credentialed steps. Everything below runs with no VSAC key in scope. + - name: Install frontend dependencies working-directory: frontend run: pnpm install --frozen-lockfile + - name: Cache Chromium + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-${{ hashFiles('e2e/pnpm-lock.yaml') }} - name: Install e2e dependencies + Chromium working-directory: e2e run: | pnpm install --frozen-lockfile npx playwright install chromium --with-deps + - name: Start the backend as Maui working-directory: backend-ts env: WORKWELL_INSTANCE: maui WORKWELL_AUTH_JWT_SECRET: maui-e2e-ci-secret-key-32-characters-minimum + # The configuration the sandbox deploys (deploy-maui-mieweb.yml), not a reduced stand-in. + WORKWELL_OFFICIAL_MEASURES: ${{ env.ROUTED_MEASURES }} run: | nohup pnpm dev > ../backend-maui.log 2>&1 & for i in $(seq 1 60); do @@ -657,6 +720,30 @@ jobs: sleep 2 done echo "::error::backend did not answer /api/version within 120s"; tail -50 ../backend-maui.log; exit 1 + # The boot line names each measure's state. A measure whose sidecar failed to resolve degrades to + # `official-pending` — it does not crash — so without this check the suite would run against two + # measures, pass, and report the pilot's configuration as green. That is exactly what happened + # between 2026-09-06 and 2026-09-08. + - name: Every routed measure actually routed + run: | + # The health check above returns as soon as the host listens, and the runnable= line is + # written just after that — so poll for it rather than reading a log that may still be a + # line short. + LINE="" + for _ in $(seq 1 15); do + LINE="$(grep -m1 'runnable=' backend-maui.log || true)" + [ -n "$LINE" ] && break + sleep 2 + done + echo "$LINE" + [ -n "$LINE" ] || { echo "::error::the backend logged no runnable= line"; tail -50 backend-maui.log; exit 1; } + IFS=',' read -ra MEASURES <<< "$ROUTED_MEASURES" + for id in "${MEASURES[@]}"; do + if printf '%s' "$LINE" | grep -q "${id}:official-pending"; then + echo "::error::${id} booted official-pending — its terminology sidecar did not resolve, so the suite would be testing a stack the pilot does not run" + exit 1 + fi + done - name: Build and start the frontend in patient mode working-directory: frontend env: diff --git a/backend-ts/src/fhir/run-aggregate.ts b/backend-ts/src/fhir/run-aggregate.ts index 60f2b110..7e382f80 100644 --- a/backend-ts/src/fhir/run-aggregate.ts +++ b/backend-ts/src/fhir/run-aggregate.ts @@ -33,17 +33,24 @@ export interface OfficialRunAggregate extends RateAggregate { } /** - * Whether ANY evaluated row of the run carries official population evidence. An errored row says + * Whether ANY evaluated row of the run FOR THIS MEASURE carries official population evidence. Scoped + * to the measure because ADR-072 admits a run that mixes engines: an ALL_PROGRAMS run over a routed + * cms125 and an authored occupational measure would otherwise answer "official" for both from + * whichever row the store returned first. An errored row says * nothing about which engine the run used, so it is skipped, and the scan continues page by page until * a row that was evaluated answers; only a run in which EVERY subject errored reads to the end, and that * run has nothing to export either way. First page of one: the overwhelmingly common case (first row * evaluated fine) costs a single row. */ -export async function runProducedOfficialEvidence(os: Pick, runId: string): Promise { +export async function runProducedOfficialEvidence( + os: Pick, + runId: string, + measureId: string, +): Promise { let limit = 1; let offset = 0; for (;;) { - const page = await os.listOutcomes(runId, { limit, offset }); + const page = await os.listOutcomes(runId, { limit, offset, measureId }); for (const row of page) { if (isEvaluationErrorEvidence(row.evidence)) continue; return officialMembership(row.evidence) !== null; @@ -61,12 +68,16 @@ export async function aggregateOfficialRun( ): Promise { const aggregator = createRateAggregator(measureId); // The artifact identity travels with the counts so BOTH exporters describe the same measure. Read off - // the first evaluated row that carries it — a run evaluates one measure with one engine, so any row is - // decisive, and a run where only some rows errored still names the artifact the rest were scored by - // (ADR-046). + // the first evaluated row that carries it — within ONE measure a run uses one engine, so any of its + // rows is decisive, and a run where only some rows errored still names the artifact the rest were + // scored by (ADR-046). let identity: OfficialReportIdentity | null = null; + // SCOPED TO THE MEASURE. An ALL_PROGRAMS run holds one row per (subject, measure) pair, so an + // unscoped scan sums every measure the run touched and returns that one number for whichever measure + // was asked about. On the pilot's 2026-09-08 nightly that served CMS125's initial population, score + // and `ecqmId` under CMS122's name on the programs overview. for (let offset = 0; ; offset += AGGREGATE_PAGE) { - const page = await os.listOutcomes(runId, { limit: AGGREGATE_PAGE, offset }); + const page = await os.listOutcomes(runId, { limit: AGGREGATE_PAGE, offset, measureId }); for (const row of page) { aggregator.add(row); if (!identity && !isEvaluationErrorEvidence(row.evidence)) identity = officialReportIdentity(row.evidence); diff --git a/backend-ts/src/program/measure-rate.test.ts b/backend-ts/src/program/measure-rate.test.ts index b1e7f60f..09ca1f79 100644 --- a/backend-ts/src/program/measure-rate.test.ts +++ b/backend-ts/src/program/measure-rate.test.ts @@ -63,3 +63,32 @@ test("a multi-rate measure carries its reviewed rate labels", async () => { const rate = await officialMeasureRate(os, "run-3", "cms137"); assert.deepEqual(rate?.rates.map((r) => [r.label, r.numer, r.effectiveDenominator]), [["Initiation", 1, 1], ["Engagement", 0, 1]]); }); + +test("an ALL_PROGRAMS run is read PER MEASURE — each measure's rate is its own rows, not the run's sum", async () => { + resetMeasureRateMemo(); + // One nightly run, two measures, one row per (subject, measure) pair — the pilot's shape since the + // 2026-09-08 flip. Before the scan was measure-scoped, both cards showed the summed aggregate and + // whichever `ecqmId` sorted first, so CMS125's 59.2% was served under CMS122's name. + const rows = [ + rec("OVERDUE", { official: { ecqmId: "122FHIR", populationResults: { ipp: true, denom: true, numer: true, denex: false, denexcep: false } } }, "cms122"), + rec("COMPLIANT", { official: { ecqmId: "122FHIR", populationResults: { ipp: true, denom: true, numer: false, denex: false, denexcep: false } } }, "cms122"), + rec("COMPLIANT", { official: { ecqmId: "125FHIR", populationResults: { ipp: true, denom: true, numer: true, denex: false, denexcep: false } } }, "cms125"), + rec("COMPLIANT", { official: { ecqmId: "125FHIR", populationResults: { ipp: true, denom: true, numer: true, denex: false, denexcep: false } } }, "cms125"), + rec("COMPLIANT", { official: { ecqmId: "125FHIR", populationResults: { ipp: true, denom: true, numer: true, denex: false, denexcep: false } } }, "cms125"), + ]; + // The fake honours `measureId` because the real stores push it into SQL; a fake that ignored it would + // pass while the shipped query narrowed, which is the harness being gentler than the caller. + const os = { + listOutcomes: async (_runId: string, opts?: { limit?: number; offset?: number; measureId?: string }) => { + const scoped = opts?.measureId ? rows.filter((r) => r.measureId === opts.measureId) : rows; + const offset = opts?.offset ?? 0; + return scoped.slice(offset, offset + (opts?.limit ?? scoped.length)); + }, + }; + const a = await officialMeasureRate(os, "run-mixed", "cms122"); + const b = await officialMeasureRate(os, "run-mixed", "cms125"); + assert.deepEqual(a?.rates.map((r) => [r.ipp, r.numer]), [[2, 1]], "cms122 sees only its own two rows"); + assert.deepEqual(b?.rates.map((r) => [r.ipp, r.numer]), [[3, 3]], "cms125 sees only its own three rows"); + assert.equal(a?.official?.ecqmId, "122FHIR"); + assert.equal(b?.official?.ecqmId, "125FHIR", "the artifact identity is the measure's, not the first row of the run"); +}); diff --git a/backend-ts/src/program/measure-rate.ts b/backend-ts/src/program/measure-rate.ts index 9c5d2ce6..c6062ba1 100644 --- a/backend-ts/src/program/measure-rate.ts +++ b/backend-ts/src/program/measure-rate.ts @@ -57,8 +57,10 @@ export async function officialMeasureRate( const key = `${runId}|${measureId}`; const hit = memo.get(key); if (hit) return hit; - // One row decides whether there is official evidence to reduce; an authored run is never paged. - if (!(await runProducedOfficialEvidence(os, runId))) return null; + // One row decides whether there is official evidence to reduce; an authored measure is never paged. + // Scoped to the measure: on an ALL_PROGRAMS run every measure's rows share the run id, so an + // unscoped question is answered by whichever measure happened to sort first. + if (!(await runProducedOfficialEvidence(os, runId, measureId))) return null; const aggregate = await aggregateOfficialRun(os, runId, measureId); const labels = officialMeasureSemantics(measureId)?.rateLabels; const rate: MeasureRate = { diff --git a/backend-ts/src/routes/runs.ts b/backend-ts/src/routes/runs.ts index 657088b4..702751a0 100644 --- a/backend-ts/src/routes/runs.ts +++ b/backend-ts/src/routes/runs.ts @@ -269,7 +269,7 @@ async function aggregateCountsForRun( // case; when it is off, the first EVALUATED row settles it (`runProducedOfficialEvidence` — an errored // row carries no engine's evidence and is skipped, never read as "not official"). const routedNow = isOfficialRouted(measureId, env as unknown as Record); - const official = routedNow || (await runProducedOfficialEvidence(os, runId)); + const official = routedNow || (await runProducedOfficialEvidence(os, runId, measureId)); if (!official) { // The authored status histogram is single-rate by construction — it reduces workflow buckets, and // a measure with no official evidence has one rate. Wrapped so the return type is uniform. A status diff --git a/backend-ts/src/stores/outcome-store.ts b/backend-ts/src/stores/outcome-store.ts index 955424d3..da09f979 100644 --- a/backend-ts/src/stores/outcome-store.ts +++ b/backend-ts/src/stores/outcome-store.ts @@ -126,8 +126,14 @@ export interface OutcomeStore { * Outcomes for one run, oldest-first. Pass `opts.limit`/`opts.offset` to page the scan (Fable H4): * the run-detail grid + the outcomes CSV must never materialize a `seed:scale` run's 120k rows in the * single-replica worker. Omitting `opts` returns every row (back-compat, small runs only). + * + * `opts.measureId` narrows the scan to ONE measure of the run. An ALL_PROGRAMS run holds a row per + * (subject, measure) pair, so a caller that sums a run's evidence without saying which measure it + * means sums every measure into one number and serves it under each measure's name — which is what + * the programs overview did until 2026-09-08. Pushed into SQL rather than filtered after the read: + * on the pilot's six-measure nightly the app-side filter would page 120,000 rows once per measure. */ - listOutcomes(runId: string, opts?: { limit?: number; offset?: number }): Promise; + listOutcomes(runId: string, opts?: { limit?: number; offset?: number; measureId?: string }): Promise; getOutcomeById(id: string): Promise; /** * Delete outcome rows older than `cutoff`, KEEPING four things (ADR-073, amended by ADR-077 d3): diff --git a/backend-ts/src/stores/postgres/outcome-store-postgres.ts b/backend-ts/src/stores/postgres/outcome-store-postgres.ts index bac1c12b..f4999585 100644 --- a/backend-ts/src/stores/postgres/outcome-store-postgres.ts +++ b/backend-ts/src/stores/postgres/outcome-store-postgres.ts @@ -140,19 +140,21 @@ export class PgOutcomeStore implements OutcomeStore { return records; } - async listOutcomes(runId: string, opts?: { limit?: number; offset?: number }): Promise { + async listOutcomes(runId: string, opts?: { limit?: number; offset?: number; measureId?: string }): Promise { // Native UUID column: a malformed run id yields no rows on the floor, so don't // let Postgres throw `invalid input syntax for type uuid` — match the contract. if (!isUuid(runId)) return []; // Optional LIMIT/OFFSET paging (Fable H4) — the id tiebreak makes paging deterministic when many // rows share an evaluated_at (all of a run's outcomes are stamped within the same run). const binds: unknown[] = [runId]; + // Narrowed BEFORE the page window, so offsets walk the measure's rows and not the run's. + const where = opts?.measureId != null ? ` AND measure_id = $${binds.push(opts.measureId)}` : ""; let page = ""; if (opts?.limit != null) page += ` LIMIT $${binds.push(Math.max(0, opts.limit))}`; if (opts?.offset != null) page += ` OFFSET $${binds.push(Math.max(0, opts.offset))}`; const { rows } = await this.pool.query( `SELECT id, run_id, subject_id, measure_id, evaluation_period, status, evidence_json, evaluated_at - FROM ${T} WHERE run_id = $1 ORDER BY evaluated_at ASC, id ASC${page}`, + FROM ${T} WHERE run_id = $1${where} ORDER BY evaluated_at ASC, id ASC${page}`, binds, ); return rows.map(toRecord); diff --git a/backend-ts/src/stores/sqlite/outcome-store-sqlite.ts b/backend-ts/src/stores/sqlite/outcome-store-sqlite.ts index 369ae201..5f7aea18 100644 --- a/backend-ts/src/stores/sqlite/outcome-store-sqlite.ts +++ b/backend-ts/src/stores/sqlite/outcome-store-sqlite.ts @@ -121,11 +121,17 @@ export class SqliteOutcomeStore implements OutcomeStore { return records; } - async listOutcomes(runId: string, opts?: { limit?: number; offset?: number }): Promise { + async listOutcomes(runId: string, opts?: { limit?: number; offset?: number; measureId?: string }): Promise { // Optional LIMIT/OFFSET paging (Fable H4); the id tiebreak keeps paging deterministic when many // rows share an evaluated_at. SQLite requires a LIMIT before OFFSET, so emit -1 (all) when only an // offset is given. const binds: unknown[] = [runId]; + // Narrowed BEFORE the page window, so offsets walk the measure's rows and not the run's. + let where = ""; + if (opts?.measureId != null) { + where = ` AND measure_id = ?`; + binds.push(opts.measureId); + } let page = ""; if (opts?.limit != null || opts?.offset != null) { page += ` LIMIT ?`; @@ -138,7 +144,7 @@ export class SqliteOutcomeStore implements OutcomeStore { const { results } = await this.db .prepare( `SELECT id, run_id, subject_id, measure_id, evaluation_period, status, evidence_json, evaluated_at - FROM outcomes WHERE run_id = ? ORDER BY evaluated_at ASC, id ASC${page}`, + FROM outcomes WHERE run_id = ?${where} ORDER BY evaluated_at ASC, id ASC${page}`, ) .bind(...binds) .all(); diff --git a/backend-ts/src/stores/store-contract.ts b/backend-ts/src/stores/store-contract.ts index e05d13f4..d98c5261 100644 --- a/backend-ts/src/stores/store-contract.ts +++ b/backend-ts/src/stores/store-contract.ts @@ -693,6 +693,33 @@ export function outcomeStoreContract( assert.deepEqual(paged, all.map((o) => o.id), "paged order matches the full (evaluated_at, id) order"); }); + test(`[${label}] listOutcomes narrows to one measure of a multi-measure run, and pages within it`, async () => { + const { runStore, outcomeStore } = await fresh(); + // An ALL_PROGRAMS run holds a row per (subject, measure) pair. A caller summing a run's evidence + // per measure must get that measure's rows only — unscoped, the programs overview served one + // measure's initial population and score under every measure's name (2026-09-08). + const run = await runStore.createRun(sampleRun("multi")); + for (let i = 0; i < 3; i++) { + await outcomeStore.recordOutcome({ runId: run.id, subjectId: `emp-${i}`, measureId: "audiogram", status: "OVERDUE", evidence: {} }); + } + for (let i = 0; i < 2; i++) { + await outcomeStore.recordOutcome({ runId: run.id, subjectId: `emp-${i}`, measureId: "hazwoper", status: "COMPLIANT", evidence: {} }); + } + assert.equal((await outcomeStore.listOutcomes(run.id)).length, 5, "unscoped still returns the whole run"); + const audiogram = await outcomeStore.listOutcomes(run.id, { measureId: "audiogram" }); + assert.equal(audiogram.length, 3); + assert.ok(audiogram.every((o) => o.measureId === "audiogram")); + assert.equal((await outcomeStore.listOutcomes(run.id, { measureId: "hazwoper" })).length, 2); + assert.deepEqual(await outcomeStore.listOutcomes(run.id, { measureId: "nobody" }), [], "a measure the run never evaluated"); + // The narrowing must apply BEFORE the page window, or an offset walks the run's rows and pages of + // the measure's own rows come back short or empty. This also pins the Pg bind numbering. + const p1 = await outcomeStore.listOutcomes(run.id, { measureId: "audiogram", limit: 2, offset: 0 }); + const p2 = await outcomeStore.listOutcomes(run.id, { measureId: "audiogram", limit: 2, offset: 2 }); + assert.equal(p1.length, 2); + assert.equal(p2.length, 1, "paging partitions the MEASURE's rows, not the run's"); + assert.deepEqual([...p1, ...p2].map((o) => o.id), audiogram.map((o) => o.id)); + }); + test(`[${label}] distinctMeasuresForRun returns the run's distinct measures, capped (Fable H4)`, async () => { const { runStore, outcomeStore } = await fresh(); const run = await runStore.createRun(sampleRun("audiogram")); diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index b797e257..362d003a 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -1,5 +1,59 @@ # Journal +## 2026-09-08 (evening) — the first six-measure run, a rate that was the wrong measure's, and an e2e suite that had stopped testing the pilot + +The flip deployed, and the programs page still read 0.0% on the four new measures. That part was +benign: the nightly runs at 12:00 UTC and had already gone before the merge, so nothing had evaluated +them. A manual ALL_PROGRAMS run confirms the routing is live — `activeMeasuresExecuted: 6`, 120,000 +evaluations — and ADR-078 with it: cases are closing under `closed_reason=OUT_OF_POPULATION` with +`closed_by` null, while the outcome itself stays MISSING_DATA. Open cases fell from 16,581 as it ran. + +**A measure rate on the overview was the whole run's, not the measure's.** `aggregateOfficialRun` +paged `listOutcomes(runId)` with no measure filter, under a comment asserting that "a run evaluates one +measure with one engine" — true of a MEASURE-scoped run, false of the ALL_PROGRAMS run the pilot +actually runs, which holds a row per (subject, measure) pair. So it summed every measure and served +that one number under whichever measure was asked about: CMS122's card carried `ecqmId: 125FHIR`, +CMS125's initial population and CMS125's 59.2%. The MeasureReport and reconciliation routes were safe — +both refuse a multi-measure run with a 422 — and the programs overview was the one caller with no +guard. The filter is now pushed into SQL in both stores rather than applied after the read; on a +six-measure nightly the app-side alternative would page 120,000 rows once per measure. Guarded by a +store-contract test that pins the narrowing BEFORE the page window (an offset must walk the measure's +rows, not the run's) and by a mixed-run unit test whose fake honours the filter — a fake that ignored +it would pass while the shipped query narrowed. + +**The Maui Playwright suite had been green-by-absence since 2026-09-03.** It is dispatch-only, so +nothing ran it across seven merges; a manual dispatch returned 32 passed, 11 failed. None was a backend +regression. Three were the stack: the job ran with `official-measures=off`, so cms2, cms130 and cms165 +booted `official-pending` and the specs #528 added for them waited 30s for chips that could not exist. +Two were ADR-078 working as designed — "chip count equals case count" is exactly the fan-out the ADR +removed, so the assertion is now a bound with the gap named. Three were role: `/runs` and `/measures` +are engineering-gated to ADMIN on the pilot profile and the specs signed in as the quality lead, so two +of them had been asserting against an AccessDenied panel and would have passed whatever the catalog +held. One was the export contract — the test wanted `employeeExternalId` where DATA_MODEL_CONTRACTS +§6.3 names `patientExternalId` on a patient deployment, so the stack was right and the test was wrong. +One was a selector collision: the panel filters gave the PCP options names containing clinic names, and +the site filter's option lookup matched one of those inside a closed select. + +The job now runs the ROUTED configuration — it vendors the six sidecars with the VSAC credential and +sets `WORKWELL_OFFICIAL_MEASURES` — because a suite that boots a stack nobody deploys is not a pilot +test. A degraded sidecar surfaces as `official-pending` rather than a crash, so the boot line is now +asserted: without that check the job would quietly run two measures and report green, which is what it +had been doing. On a public repo the credential is scoped to one step, that step runs before the +frontend and e2e trees are installed, and the completed expansions are deliberately NOT cached — an +Actions cache is restorable by a fork pull request, and licensed value-set content should not be +reachable from code we do not control. + +The suite went from 43 tests to 26 with more covered: six measures rather than two, and two vacuous +assertions made real. It was never slow — of an 8m24s run the passing tests were about 20 seconds and +the rest was failures burning their timeouts and retrying, so fixing them was the speedup. `workers: 1` +to 4 and one shared sign-in per role instead of about forty were the rest. The roster filter selects +also had no accessible name at all (Chrome computed none from the wrapping label), so Status, PCP, Age, +Sex and Page size now carry `aria-label`s like the three beside them already did. + +**Still open:** the live six-measure run is slow — roughly 17 (subject, measure) pairs a second against +Neon, where the in-memory flip gate does 50 subjects a second. The cost looks like per-pair writes +(outcome, case, audit event) rather than CQL, and every nightly now pays it for hours. Not investigated. + ## 2026-09-08 (later) — all six measures on the sandbox, and a patient outside a measure is not a case (ADR-078) The owner looked at the programs page with two measure cards on it and asked why, when the pilot diff --git a/e2e/.gitignore b/e2e/.gitignore index c6f02809..810c46f4 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -4,3 +4,6 @@ /blob-report/ /playwright/.cache/ node_modules/ + +# Signed-in browser state written by global-setup.ts (holds a live demo token) +/.auth/ diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index de28cd41..62213821 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -1,6 +1,15 @@ -import { request } from "@playwright/test"; -import { ensureCompletedRun } from "./tests/maui/helpers"; +import { chromium, request } from "@playwright/test"; +import { ensureCompletedRun, MAUI_ACCOUNTS, MAUI_PASSWORD, storageStatePath } from "./tests/maui/helpers"; +/** + * Two jobs, both done ONCE for the whole suite rather than per test. + * + * 1. A COMPLETED ALL_PROGRAMS run must exist, or every roster/chip/case assertion is about an empty + * stack. + * 2. A signed-in browser state per role. Logging in inside `beforeEach` cost the Maui project ~40 + * sign-in round trips — the single largest fixed cost once the failing tests stopped burning their + * timeouts. Each spec now picks a role's storage state and starts on the page it is about. + */ export default async function globalSetup() { if (process.env.PLAYWRIGHT_PROFILE !== "maui") return; @@ -10,4 +19,23 @@ export default async function globalSetup() { } finally { await api.dispose(); } + + const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000"; + const browser = await chromium.launch(); + try { + // Only the roles the specs actually adopt. The clinician (viewer) and quality-staff sign-ins are + // themselves under test in auth.spec.ts, so they stay explicit there. + for (const account of [MAUI_ACCOUNTS.qualityLead, MAUI_ACCOUNTS.admin]) { + const page = await browser.newPage({ baseURL }); + await page.goto("/login"); + await page.locator("#email").fill(account.email); + await page.locator("#password").fill(MAUI_PASSWORD); + await page.getByRole("button", { name: /sign in/i }).click(); + await page.waitForURL(/\/programs/, { timeout: 30_000 }); + await page.context().storageState({ path: storageStatePath(account.email) }); + await page.close(); + } + } finally { + await browser.close(); + } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 64597ece..c4ec3191 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -5,7 +5,12 @@ export default defineConfig({ globalSetup: "./global-setup.ts", timeout: 60_000, retries: 1, - workers: 1, + // The Maui stack this suite boots is a single backend process on the SQLite floor, so the ceiling is + // its event loop rather than the runner's 4 vCPUs. Four workers on a read-mostly suite measured well + // inside that; the two specs that WRITE (a manual run, an outreach POST) are serialized by their own + // `test.describe.configure({ mode: "serial" })` rather than by pinning the whole project to one + // worker, which is what made the project take 8 minutes of wall clock to do 20 seconds of work. + workers: process.env.CI ? 4 : undefined, reporter: [["html", { open: "never" }], ["list"]], use: { // STAGING by default, never production: this suite mutates (it triggers runs and POSTs outreach), diff --git a/e2e/tests/maui/auth.spec.ts b/e2e/tests/maui/auth.spec.ts index cb62c076..fd8a51e2 100644 --- a/e2e/tests/maui/auth.spec.ts +++ b/e2e/tests/maui/auth.spec.ts @@ -18,14 +18,17 @@ const ACCOUNTS = [ MAUI_ACCOUNTS.admin, ]; +// This spec is ABOUT signing in, so it drives the form and takes no shared storage state. test.describe("Maui authentication", () => { - for (const account of ACCOUNTS) { - test(`${account.email} logs in and lands on /programs`, async ({ page }) => { + test("every Maui account logs in and lands on /programs", async ({ page }) => { + for (const account of ACCOUNTS) { await loginAs(page, account.email); - await expect(page).toHaveURL(/\/programs/); + await expect(page, `${account.email} should land on /programs`).toHaveURL(/\/programs/); await expectNoErrorPage(page); - }); - } + await page.getByRole("button", { name: /log ?out|sign out/i }).click(); + await expect(page).toHaveURL(/\/login/, { timeout: 10_000 }); + } + }); test("bad password shows a visible error", async ({ page }) => { await page.goto("/login"); diff --git a/e2e/tests/maui/case-workflow.spec.ts b/e2e/tests/maui/case-workflow.spec.ts index 96763170..234020f2 100644 --- a/e2e/tests/maui/case-workflow.spec.ts +++ b/e2e/tests/maui/case-workflow.spec.ts @@ -6,6 +6,10 @@ test.beforeEach(() => { }); test.describe("Maui case workflow", () => { + // This file MUTATES cases (an outreach POST, an assignment), so its tests run in order rather than + // racing each other for the same open case. + test.describe.configure({ mode: "serial" }); + test("open an OVERDUE cms125 case and exercise the case-manager actions", async ({ page }) => { test.setTimeout(120_000); await loginAs(page, MAUI_ACCOUNTS.qualityLead.email); diff --git a/e2e/tests/maui/exports.spec.ts b/e2e/tests/maui/exports.spec.ts index 41d2d918..f20a1a5b 100644 --- a/e2e/tests/maui/exports.spec.ts +++ b/e2e/tests/maui/exports.spec.ts @@ -5,8 +5,13 @@ test.beforeEach(() => { test.skip(process.env.PLAYWRIGHT_PROFILE !== "maui", "maui profile only"); }); +// The PATIENT spelling of the §6.3 case-export contract (`docs/DATA_MODEL_CONTRACTS.md`): on a +// deployment whose `DEPLOYMENT_PROFILE.subjectTerm` is "patient", the two subject columns are named +// `patientExternalId`/`patientName` and every other header and the column order are unchanged. This +// list said `employeeExternalId` — the DEFAULT profile's spelling — so the assertion contradicted the +// contract the export is written to, and the Maui stack was right and the test was wrong. const EXPECTED_HEADERS = [ - "caseId", "employeeExternalId", "employeeName", "role", "site", + "caseId", "patientExternalId", "patientName", "role", "site", "measureName", "measureVersion", "evaluationPeriod", "status", "priority", "assignee", "currentOutcomeStatus", "nextAction", "lastRunId", "createdAt", "updatedAt", "closedAt", "latestOutreachDeliveryStatus", @@ -45,9 +50,9 @@ test.describe("Maui case CSV export", () => { expect(headers, `CSV header should include '${expected}'`).toContain(expected); } - // All employeeName values should be Maui roster names (no emp-/twh identifiers) - const nameIdx = headers.indexOf("employeeName"); - const extIdx = headers.indexOf("employeeExternalId"); + // All patient names should be Maui roster names (no emp-/twh identifiers) + const nameIdx = headers.indexOf("patientName"); + const extIdx = headers.indexOf("patientExternalId"); expect(nameIdx).toBeGreaterThan(-1); expect(extIdx).toBeGreaterThan(-1); diff --git a/e2e/tests/maui/helpers.ts b/e2e/tests/maui/helpers.ts index 16a43787..09ee4d7e 100644 --- a/e2e/tests/maui/helpers.ts +++ b/e2e/tests/maui/helpers.ts @@ -1,8 +1,45 @@ import { expect, type Page, type APIRequestContext } from "@playwright/test"; +import { mkdirSync } from "node:fs"; +import path from "node:path"; export const API_BASE = process.env.PLAYWRIGHT_API_BASE_URL ?? "http://localhost:8080"; export const MAUI_PASSWORD = "Workwell123!"; +/** + * Where `global-setup.ts` parks each role's signed-in browser state, so a spec adopts a session with + * `test.use({ storageState: ... })` instead of driving the login form again. Gitignored: it holds a + * live token for a demo account. + */ +const STATE_DIR = path.join(__dirname, "..", "..", ".auth"); +export function storageStatePath(email: string): string { + mkdirSync(STATE_DIR, { recursive: true }); + return path.join(STATE_DIR, `${email.replace(/[^a-z0-9]+/gi, "-")}.json`); +} + +/** + * The pilot profile hides the engineering surfaces from every role but ADMIN + * (`frontend/lib/public-demo.ts`), so `/runs` and `/measures` render AccessDenied for the quality lead. + * A spec about those pages adopts this state; one about the quality lead's own workflow must not. + */ +export const AS_QUALITY_LEAD = { storageState: storageStatePath("quality-lead@maui.workwell.dev") }; +export const AS_ADMIN = { storageState: storageStatePath("admin@maui.workwell.dev") }; + +/** + * The ACO's whole computable set, which the Maui stack has ROUTED since ADR-078 — in CI too, since the + * e2e job vendors the terminology sidecars with the VSAC credential. Each is a roster column, a + * programs card and a set of status chips, so one list drives every spec that enumerates measures. + * `mips` is the MIPS Quality Id the crosswalk renders beside the CMS id + * (`backend-ts/src/measure/measure-identity.ts`). + */ +export const ROUTED_MEASURES = [ + { id: "cms122", cms: "CMS122", mips: "001", label: "Diabetes" }, + { id: "cms125", cms: "CMS125", mips: "112", label: "Breast Cancer Screening" }, + { id: "cms2", cms: "CMS2", mips: "134", label: "Depression" }, + { id: "cms130", cms: "CMS130", mips: "113", label: "Colorectal" }, + { id: "cms165", cms: "CMS165", mips: "236", label: "Blood Pressure" }, + { id: "cms137", cms: "CMS137", mips: "305", label: "Substance Use" }, +] as const; + export const MAUI_ACCOUNTS = { qualityLead: { email: "quality-lead@maui.workwell.dev", role: "ROLE_CASE_MANAGER" }, qualityStaff: { email: "quality-staff@maui.workwell.dev", role: "ROLE_CASE_MANAGER" }, diff --git a/e2e/tests/maui/jelly-beans.spec.ts b/e2e/tests/maui/jelly-beans.spec.ts index c4fdf939..1b85b2ca 100644 --- a/e2e/tests/maui/jelly-beans.spec.ts +++ b/e2e/tests/maui/jelly-beans.spec.ts @@ -1,21 +1,13 @@ -import { test, expect } from "@playwright/test"; -import { MAUI_ACCOUNTS, loginAs, expectNoErrorPage } from "./helpers"; +import { test, expect, type Page } from "@playwright/test"; +import { AS_QUALITY_LEAD, ROUTED_MEASURES, expectNoErrorPage } from "./helpers"; test.beforeEach(() => { test.skip(process.env.PLAYWRIGHT_PROFILE !== "maui", "maui profile only"); }); -const OPEN_BUCKETS = ["DUE_SOON", "OVERDUE", "MISSING_DATA"] as const; +test.use(AS_QUALITY_LEAD); -// The ACO's five (ADR-072). `hypertension` was here until U1 replaced the pilot's runnable set; it is -// an AUTHORED occupational measure and has no place on a patient roster. -const MEASURES = [ - { id: "cms125", label: "Breast Cancer Screening" }, - { id: "cms122", label: "Diabetes" }, - { id: "cms2", label: "Depression" }, - { id: "cms130", label: "Colorectal" }, - { id: "cms165", label: "Blood Pressure" }, -] as const; +const OPEN_BUCKETS = ["DUE_SOON", "OVERDUE", "MISSING_DATA"] as const; interface ChipInfo { bucket: string; @@ -23,7 +15,7 @@ interface ChipInfo { href: string; } -async function readChips(page: import("@playwright/test").Page, measureId: string): Promise { +async function readChips(page: Page, measureId: string): Promise { const chips: ChipInfo[] = []; const chipLinks = page.locator(`a[href*="measureId=${measureId}"][href*="outcome="]`); const count = await chipLinks.count(); @@ -46,61 +38,67 @@ async function readChips(page: import("@playwright/test").Page, measureId: strin test.describe("Maui status chips (jelly beans)", () => { test.beforeEach(async ({ page }) => { - await loginAs(page, MAUI_ACCOUNTS.qualityLead.email); await page.goto("/programs"); await expect(page.getByRole("heading").first()).toBeVisible({ timeout: 20_000 }); }); - for (const measure of MEASURES) { - test(`${measure.id}: chip counts match case list and Open Worklist`, async ({ page }) => { - await expect(page.locator(`a[href*="measureId=${measure.id}"][href*="outcome="]`).first()).toBeVisible({ timeout: 30_000 }); + // CHEAP pass over every routed measure: each one renders chips, and the chips are consistent with + // the worklist link beside them. This replaces a per-measure drill-down that navigated and reloaded + // once per chip — six measures × three buckets × two page loads was most of the project's runtime. + test("every routed measure renders status chips consistent with its worklist", async ({ page }) => { + for (const measure of ROUTED_MEASURES) { + const firstChip = page.locator(`a[href*="measureId=${measure.id}"][href*="outcome="]`).first(); + await expect(firstChip, `${measure.cms} is routed, so its card must render chips`).toBeVisible({ timeout: 30_000 }); + const chips = await readChips(page, measure.id); - test.expect(chips.length).toBeGreaterThan(0); + expect(chips.length, `${measure.cms} should have at least one open-bucket chip`).toBeGreaterThan(0); - // Open Worklist total for this measure const worklistLink = page.locator(`a[href*="measureId=${measure.id}"]`).filter({ hasText: /Open Worklist/i }); await expect(worklistLink.first()).toBeVisible({ timeout: 10_000 }); - const wlText = await worklistLink.first().textContent(); - const wlMatch = wlText?.match(/(\d+)/); - expect(wlMatch, "Open Worklist should include its case count").not.toBeNull(); - const worklistTotal = Number(wlMatch?.[1]); + const worklistTotal = Number(((await worklistLink.first().textContent()) ?? "").match(/(\d+)/)?.[1]); + // NOT equality. A chip counts OUTCOMES in a bucket; the worklist counts CASES. Since ADR-078 a + // subject the official executor puts outside the initial population is persisted MISSING_DATA + // but opens no case, so on every officially routed measure the chip sum is the larger number. + // Asserting equality here is asserting the fan-out ADR-078 removed. const chipSum = chips.reduce((sum, c) => sum + c.count, 0); - expect(chipSum, `chip sum (${chipSum}) should equal Open Worklist (${worklistTotal})`).toBe(worklistTotal); + expect( + chipSum, + `${measure.cms}: chip sum (${chipSum}) must cover the open worklist (${worklistTotal}); the gap is out-of-population subjects`, + ).toBeGreaterThanOrEqual(worklistTotal); + } + }); - for (const chip of chips) { - await page.goto(chip.href); - await expect(page).toHaveURL(new RegExp(`measureId=${measure.id}&outcome=${chip.bucket}`)); - await expectNoErrorPage(page); + // The EXPENSIVE structural check — that a chip's href really filters the case list — is worth doing + // properly, but once. cms125 is the measure whose corpus distribution the pilot was designed around. + test("a cms125 chip drills into a case list filtered to that bucket, and the filter is URL-backed", async ({ page }) => { + const chips = await readChips(page, "cms125"); + expect(chips.length).toBeGreaterThan(0); - // Reload to confirm filters are URL-backed and survive refresh - await page.reload(); - await expect(page).toHaveURL(new RegExp(`measureId=${measure.id}&outcome=${chip.bucket}`)); - // Wait for the list to settle: either a visible case link or one of the page's empty states. - const firstCase = page.locator("a[href^='/cases/']").filter({ visible: true }).first(); - const emptyState = page.getByText(/^No (open |excluded |closed )?cases|^No results match/); - await expect(firstCase.or(emptyState)).toBeVisible({ timeout: 30_000 }); + for (const chip of chips) { + await page.goto(chip.href); + await expect(page).toHaveURL(new RegExp(`measureId=cms125&outcome=${chip.bucket}`)); + await page.reload(); + await expect(page).toHaveURL(new RegExp(`measureId=cms125&outcome=${chip.bucket}`)); + await expectNoErrorPage(page); - // Count DISTINCT cases: a row renders more than one link to the same case (name + "View"), - // and the mobile card layout duplicates rows in the DOM. - const hrefs = await page.locator("a[href^='/cases/']").evaluateAll((els) => - els.map((el) => (el as HTMLAnchorElement).getAttribute("href") ?? ""), - ); - const rowCount = new Set(hrefs.filter((h) => /^\/cases\/[^?]+$/.test(h))).size; - expect(rowCount, `distinct cases (${rowCount}) should match chip count (${chip.count}) for ${chip.bucket}`).toBe(chip.count); - } - }); - } + // Wait for the list to settle: either a visible case link or one of the page's empty states. + const firstCase = page.locator("a[href^='/cases/']").filter({ visible: true }).first(); + const emptyState = page.getByText(/^No (open |excluded |closed )?cases|^No results match/); + await expect(firstCase.or(emptyState)).toBeVisible({ timeout: 30_000 }); - test("filters survive reload (URL-backed)", async ({ page }) => { - await expect(page.locator('a[href*="measureId=cms125"][href*="outcome="]').first()).toBeVisible({ timeout: 30_000 }); - const chips = await readChips(page, "cms125"); - test.expect(chips.length).toBeGreaterThan(0); - const chip = chips[0]; - await page.goto(chip.href); - await expect(page).toHaveURL(new RegExp(`measureId=cms125&outcome=${chip.bucket}`)); - await page.reload(); - await expect(page).toHaveURL(new RegExp(`measureId=cms125&outcome=${chip.bucket}`)); - await expectNoErrorPage(page); + // Count DISTINCT cases: a row renders more than one link to the same case (name + "View"), + // and the mobile card layout duplicates rows in the DOM. + const hrefs = await page.locator("a[href^='/cases/']").evaluateAll((els) => + els.map((el) => (el as HTMLAnchorElement).getAttribute("href") ?? ""), + ); + const rowCount = new Set(hrefs.filter((h) => /^\/cases\/[^?]+$/.test(h))).size; + // Again a bound, not equality, and for the same ADR-078 reason: the bucket's outcomes include + // subjects outside the population, who have no case to list. + expect( + rowCount, + `cases listed (${rowCount}) must not exceed the ${chip.bucket} chip (${chip.count})`, + ).toBeLessThanOrEqual(chip.count); + } }); }); diff --git a/e2e/tests/maui/measures.spec.ts b/e2e/tests/maui/measures.spec.ts index eae4b4a6..9b98ca1a 100644 --- a/e2e/tests/maui/measures.spec.ts +++ b/e2e/tests/maui/measures.spec.ts @@ -1,21 +1,28 @@ import { test, expect } from "@playwright/test"; -import { MAUI_ACCOUNTS, loginAs, expectNoErrorPage } from "./helpers"; +import { AS_ADMIN, AS_QUALITY_LEAD, ROUTED_MEASURES, expectNoErrorPage } from "./helpers"; test.beforeEach(() => { test.skip(process.env.PLAYWRIGHT_PROFILE !== "maui", "maui profile only"); }); test.describe("Maui measures catalog", () => { - test.beforeEach(async ({ page }) => { - await loginAs(page, MAUI_ACCOUNTS.qualityLead.email); - }); + // /measures is an ENGINEERING surface: on the pilot profile `canSeeEngineering` admits ADMIN only, + // so the quality lead gets an AccessDenied panel and no catalog. The spec used to sign in as the + // quality lead and wait 20s for a crosswalk label that was never going to render. + test.use(AS_ADMIN); - test("Identity column shows MIPS 001 · CMS122 and MIPS 112 · CMS125", async ({ page }) => { + test("the identity column carries the MIPS crosswalk for every routed measure", async ({ page }) => { await page.goto("/measures"); - await expect(page.getByText("MIPS 001 · CMS122").first()).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText("MIPS 112 · CMS125").first()).toBeVisible({ timeout: 20_000 }); + for (const m of ROUTED_MEASURES) { + await expect(page.getByText(`MIPS ${m.mips} · ${m.cms}`).first()).toBeVisible({ timeout: 20_000 }); + } await expectNoErrorPage(page); }); +}); + +test.describe("Maui measure detail", () => { + // The per-measure page is NOT engineering-gated — it is where the quality lead reads a measure. + test.use(AS_QUALITY_LEAD); test("measure detail for cms125 opens without error", async ({ page }) => { await page.goto("/programs/cms125"); diff --git a/e2e/tests/maui/pending-readiness.spec.ts b/e2e/tests/maui/pending-readiness.spec.ts index 4801d233..b5447296 100644 --- a/e2e/tests/maui/pending-readiness.spec.ts +++ b/e2e/tests/maui/pending-readiness.spec.ts @@ -1,54 +1,55 @@ import { test, expect } from "@playwright/test"; -import { API_BASE, MAUI_PASSWORD } from "./helpers"; +import { API_BASE, AS_ADMIN, AS_QUALITY_LEAD, MAUI_PASSWORD } from "./helpers"; test.beforeEach(() => { test.skip(process.env.PLAYWRIGHT_PROFILE !== "maui", "maui profile only"); }); -test.describe("Maui readiness", () => { - test("programs page lists only the three Maui measures", async ({ page }) => { - await page.goto("/login"); - await page.locator("#email").fill("quality-lead@maui.workwell.dev"); - await page.locator("#password").fill(MAUI_PASSWORD); - await page.getByRole("button", { name: /sign in/i }).click(); - await expect(page).toHaveURL(/\/programs/, { timeout: 15_000 }); +/** Occupational content that must never surface on a patient deployment. */ +const OCCUPATIONAL = ["HAZWOPER", "Audiogram", "TB Surveillance"]; + +test.describe("Maui readiness — signed in", () => { + test.use(AS_QUALITY_LEAD); + + test("the programs page carries no occupational measures", async ({ page }) => { + await page.goto("/programs"); await expect(page.getByRole("heading").first()).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText("HAZWOPER")).not.toBeVisible(); - await expect(page.getByText("Audiogram")).not.toBeVisible(); - await expect(page.getByText("TB Surveillance")).not.toBeVisible(); + for (const label of OCCUPATIONAL) await expect(page.getByText(label)).not.toBeVisible(); }); +}); - test("measures page lists no occupational measures", async ({ page }) => { - await page.goto("/login"); - await page.locator("#email").fill("quality-lead@maui.workwell.dev"); - await page.locator("#password").fill(MAUI_PASSWORD); - await page.getByRole("button", { name: /sign in/i }).click(); - await expect(page).toHaveURL(/\/programs/, { timeout: 15_000 }); +test.describe("Maui readiness — the catalog", () => { + // As the quality lead this page is an AccessDenied panel, so asserting that HAZWOPER is absent from + // it passed without ever loading a catalog. ADMIN is the role that can see the list this is about. + test.use(AS_ADMIN); + + test("the measures catalog carries no occupational measures", async ({ page }) => { await page.goto("/measures"); await expect(page.getByRole("heading").first()).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText("HAZWOPER")).not.toBeVisible(); - await expect(page.getByText("Audiogram")).not.toBeVisible(); + for (const label of OCCUPATIONAL) await expect(page.getByText(label)).not.toBeVisible(); }); +}); + +test.describe("Maui readiness — signed out", () => { + // No storage state: these are about what an ANONYMOUS visitor sees. + test.use({ storageState: { cookies: [], origins: [] } }); - test("login page has no OSHA text and no sandbox links", async ({ page }) => { + test("no OSHA wording, no public-sandbox affordances, and /sandbox redirects to login", async ({ page }) => { await page.goto("/login"); - await expect(page.getByText("OSHA")).not.toBeVisible(); - await expect(page.getByText("Open sandbox")).not.toBeVisible(); - await expect(page.getByText("Skip login")).not.toBeVisible(); - }); + for (const text of ["OSHA", "Open sandbox", "Skip login"]) { + await expect(page.getByText(text)).not.toBeVisible(); + } - test("root page has no sandbox text", async ({ page }) => { await page.goto("/"); - await expect(page.getByText("Open sandbox")).not.toBeVisible(); - await expect(page.getByText("Public sandbox")).not.toBeVisible(); - }); + for (const text of ["Open sandbox", "Public sandbox"]) { + await expect(page.getByText(text)).not.toBeVisible(); + } - test("sandbox route redirects to login", async ({ page }) => { await page.goto("/sandbox"); await expect(page).toHaveURL(/\/login/, { timeout: 10_000 }); }); - test("TWH viewer account cannot authenticate on Maui stack", async ({ request }) => { + test("a TWH account cannot authenticate against the Maui stack", async ({ request }) => { const res = await request.post(`${API_BASE}/api/auth/login`, { data: { email: "viewer@workwell.dev", password: MAUI_PASSWORD }, }); diff --git a/e2e/tests/maui/roster.spec.ts b/e2e/tests/maui/roster.spec.ts index 9794b05e..2582ae85 100644 --- a/e2e/tests/maui/roster.spec.ts +++ b/e2e/tests/maui/roster.spec.ts @@ -1,62 +1,52 @@ import { test, expect } from "@playwright/test"; -import { MAUI_ACCOUNTS, loginAs, expectNoErrorPage } from "./helpers"; +import { AS_QUALITY_LEAD, ROUTED_MEASURES, expectNoErrorPage } from "./helpers"; test.beforeEach(() => { test.skip(process.env.PLAYWRIGHT_PROFILE !== "maui", "maui profile only"); }); -test.describe("Maui compliance roster", () => { - test.beforeEach(async ({ page }) => { - await loginAs(page, MAUI_ACCOUNTS.qualityLead.email); - }); - - test("roster shows 48 total rows across pages", async ({ page }) => { - await page.goto("/compliance"); - await expect(page.getByRole("heading", { name: /Individual Compliance/i })).toBeVisible({ timeout: 20_000 }); +// The roster is the quality lead's page; the session comes from global setup rather than a sign-in +// per test. +test.use(AS_QUALITY_LEAD); - // The total count is rendered near the pagination controls. - const totalText = page.getByText(/of\s*48|48\s*(patients|rows|total)/i).first(); - await expect(totalText).toBeVisible({ timeout: 20_000 }); - }); +/** Land on the roster and wait for the page, not for an arbitrary timeout. */ +async function openRoster(page: import("@playwright/test").Page, query = "") { + await page.goto(`/compliance${query}`); + await expect(page.getByRole("heading", { name: /Individual Compliance/i })).toBeVisible({ timeout: 20_000 }); +} - test("columns are the ACO measures this stack can run, with correct crosswalk labels", async ({ page }) => { - // A roster column is a measure that is Active in the catalog, in the quality panel, AND runnable - // (ADR-072). The Maui profile lists the ACO's six; the e2e stack routes no official-only measure - // (README-maui — the artifacts need their gitignored terminology sidecars), so the columns here are - // the two authored ones. On the deployed sandbox each of cms2/cms130/cms165/cms137 becomes a column - // the day its flip routes it — the panel now holds all six, which it did not until 2026-09-06. - await page.goto("/compliance"); - for (const label of [/MIPS 001 · CMS122/, /MIPS 112 · CMS125/]) { - await expect(page.getByRole("columnheader", { name: label })).toBeVisible({ timeout: 20_000 }); +test.describe("Maui compliance roster", () => { + test("shows the whole roster, the ACO's routed measure columns, and no occupational ones", async ({ page }) => { + await openRoster(page); + + // Three assertions that used to be three page loads. The roster is 48 corpus patients, one column + // per RUNNABLE measure in the panel (ADR-072), and nothing from the occupational catalog. + await expect(page.getByText(/of\s*48|48\s*(patients|rows|total)/i).first()).toBeVisible({ timeout: 20_000 }); + + for (const m of ROUTED_MEASURES) { + await expect( + page.getByRole("columnheader", { name: new RegExp(`MIPS ${m.mips} · ${m.cms}`) }), + `${m.cms} is routed on this stack, so it is a roster column`, + ).toBeVisible({ timeout: 20_000 }); } - // No occupational measure columns, and no leftover authored one. - const headers = await page.getByRole("columnheader").allTextContents(); - const headerText = headers.join(" "); + const headerText = (await page.getByRole("columnheader").allTextContents()).join(" "); for (const absent of ["HAZWOPER", "Audiogram", "TB Surveillance", "Hypertension"]) { expect(headerText, `${absent} must not appear on the pilot roster`).not.toContain(absent); } }); test("status filter narrows rows", async ({ page }) => { - await page.goto("/compliance"); - await expect(page.getByRole("heading", { name: /Individual Compliance/i })).toBeVisible({ timeout: 20_000 }); - - const statusSelect = page.getByLabel("Status"); - await expect(statusSelect).toBeVisible({ timeout: 10_000 }); - await statusSelect.selectOption({ label: "Overdue" }); + await openRoster(page); + await page.getByLabel("Status", { exact: true }).selectOption({ label: "Overdue" }); - // Wait for the filtered state const rows = page.locator("tbody tr"); await expect.poll(() => rows.count(), { timeout: 10_000 }).toBeLessThan(48); - const rowCount = await rows.count(); - expect(rowCount).toBeGreaterThan(0); - expect(rowCount).toBeLessThan(48); + expect(await rows.count()).toBeGreaterThan(0); }); test("site filter narrows to Kihei Clinic", async ({ page }) => { - await page.goto("/compliance"); - await expect(page.getByRole("heading", { name: /Individual Compliance/i })).toBeVisible({ timeout: 20_000 }); + await openRoster(page); // The site filter is the GLOBAL header filter (a custom combobox, rendered twice for the // responsive layouts), not a roster control. The roster footer reads " patients". @@ -65,58 +55,49 @@ test.describe("Maui compliance roster", () => { const siteFilter = page.getByRole("combobox", { name: "Filter by site" }).filter({ visible: true }).first(); await siteFilter.click(); - await page.getByRole("option", { name: "Kihei Clinic" }).first().click(); + // EXACT, or this matches an