From 11bee7fb9590e5569f1961fac416c6c51502c564 Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Thu, 5 Nov 2026 15:24:05 -0500 Subject: [PATCH 1/7] Clear loading on an edit, and answer form-js fields without a wait An edit outdates the evaluation in flight, so the session no longer reports loading until it sends the new answers, as its interface says. The form-js test helper's focusout already hands each answer to the form before the post-import redraw can reset it, so drop the fixed 100ms settle(). Rename type() to answer(), which reads unambiguously in an import list. Co-Authored-By: Claude Opus 5.5 --- builder-frontend/README.md | 11 ++--- .../src/components/screener/Screener.test.tsx | 8 ++-- .../screenerEditor/preview/Preview.test.tsx | 7 ++-- .../screeningSession.test.tsx | 40 ++++++++++--------- .../screeningSession/screeningSession.tsx | 2 + builder-frontend/src/testing/formJs.ts | 8 ++-- 6 files changed, 38 insertions(+), 38 deletions(-) diff --git a/builder-frontend/README.md b/builder-frontend/README.md index 5cd0aa6f..c0b63f7d 100644 --- a/builder-frontend/README.md +++ b/builder-frontend/README.md @@ -74,11 +74,12 @@ on the service; `subscribe()` returns an unsubscribe function and updates fire ## Testing with a real form-js form -Tests that type into a rendered form-js form use `src/testing/formJs.ts`. form-js -redraws a form about 60ms after an import, wiping anything typed before then, and -passes typed text on only after a 300ms pause or when the field loses focus -(`focusout`, which Preact uses for `onBlur`). The helpers wait out the redraw and -send `focusout` so each answer reaches the form at once. +Tests that type into a rendered form-js form use `answer()` from +`src/testing/formJs.ts`. form-js passes typed text on only after a 300ms pause or +when the field loses focus (`focusout`, which Preact uses for `onBlur`), and it +redraws a form about 60ms after an import, resetting any field whose text it +hasn't received yet. `answer()` sends `focusout`, so each answer reaches the form +at once and survives the redraw. ## Print summary diff --git a/builder-frontend/src/components/screener/Screener.test.tsx b/builder-frontend/src/components/screener/Screener.test.tsx index fb12b19f..ae05e7e2 100644 --- a/builder-frontend/src/components/screener/Screener.test.tsx +++ b/builder-frontend/src/components/screener/Screener.test.tsx @@ -7,7 +7,7 @@ import { fetchPublishedScreener, } from "@/api/publishedScreener"; import type { ScreenerResult } from "@/types"; -import { input, settle, type, until } from "@/testing/formJs"; +import { answer, input, until } from "@/testing/formJs"; const integration = vi.hoisted(() => ({ enabled: true, @@ -98,8 +98,7 @@ describe("published screener integration lifecycle", () => { ); expect(input("City")!.value).toBe("Philadelphia"); - await settle(); - type("City", "Pittsburgh"); + answer("City", "Pittsburgh"); await until(() => expect(integration.result).toHaveBeenCalledTimes(2)); expect(integration.result).toHaveBeenLastCalledWith( { custom: { city: "Pittsburgh" } }, @@ -216,8 +215,7 @@ describe("published screener integration lifecycle", () => { await until(() => expect(input("City")).toBeDefined()); expect(container.textContent).not.toContain("Waiting for information"); expect(evaluatePublishedScreener).not.toHaveBeenCalled(); - await settle(); - type("City", "Pittsburgh"); + answer("City", "Pittsburgh"); await until(() => expect(container.textContent).toContain("Eligible")); expect(integration.result).not.toHaveBeenCalled(); }); diff --git a/builder-frontend/src/components/screenerEditor/preview/Preview.test.tsx b/builder-frontend/src/components/screenerEditor/preview/Preview.test.tsx index f8bd835b..01df3046 100644 --- a/builder-frontend/src/components/screenerEditor/preview/Preview.test.tsx +++ b/builder-frontend/src/components/screenerEditor/preview/Preview.test.tsx @@ -3,7 +3,7 @@ import { render } from "solid-js/web"; import { afterEach, expect, it, vi } from "vitest"; import type { ScreenerResult } from "@/types"; -import { input, settle, type, until } from "@/testing/formJs"; +import { answer, input, until } from "@/testing/formJs"; const previewResults = vi.hoisted(() => ({ props: undefined as any })); vi.mock("@/api/screener", () => ({ evaluateScreener: vi.fn() })); @@ -56,9 +56,8 @@ it("evaluates the working screener on open and keeps the last results when an ev answer: "", }), ); - await settle(); - type("Answer", "first"); + answer("Answer", "first"); await until(() => expect(evaluateScreener).toHaveBeenCalledTimes(2)); await until(() => expect(container.querySelector('[role="status"]')?.textContent).toContain( @@ -74,7 +73,7 @@ it("evaluates the working screener on open and keeps the last results when an ev expect.objectContaining({ answer: "first" }), ); - type("Answer", "second"); + answer("Answer", "second"); await until(() => expect(container.querySelector('[role="alert"]')).not.toBeNull(), ); diff --git a/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx b/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx index a12e905e..37842c50 100644 --- a/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx +++ b/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx @@ -9,7 +9,7 @@ import { type ScreeningAnswers, type ScreeningSessionOptions, } from "./screeningSession"; -import { input, settle, type, until } from "@/testing/formJs"; +import { answer, input, until } from "@/testing/formJs"; const schema = { type: "default", @@ -74,7 +74,6 @@ async function start( await until(() => expect(container.querySelector(".fjs-form")).not.toBeNull(), ); - await settle(); return { session, container, dispose, setSchema }; } @@ -84,9 +83,9 @@ describe("screening session", () => { const { session } = await start({ evaluate }); expect(evaluate).not.toHaveBeenCalled(); - type("Answer", "fir"); + answer("Answer", "fir"); await until(() => expect(session.stale()).toBe(true)); - type("Answer", "first"); + answer("Answer", "first"); expect(session.loading()).toBe(false); await until(() => expect(evaluate).toHaveBeenCalledOnce()); @@ -110,10 +109,10 @@ describe("screening session", () => { .mockResolvedValueOnce(result("FALSE")); const { session } = await start({ evaluate, onResults }); - type("Answer", "older"); + answer("Answer", "older"); await until(() => expect(evaluate).toHaveBeenCalledOnce()); expect(session.loading()).toBe(true); - type("Answer", "newer"); + answer("Answer", "newer"); await until(() => expect(session.results()).toEqual(result("FALSE"))); older.resolve(result("TRUE")); await older.promise; @@ -132,13 +131,16 @@ describe("screening session", () => { const evaluate = vi.fn(() => inFlight.promise); const { session } = await start({ evaluate, onResults }); - type("Answer", "first"); + answer("Answer", "first"); await until(() => expect(evaluate).toHaveBeenCalledOnce()); - type("Answer", "second"); + expect(session.loading()).toBe(true); + answer("Answer", "second"); + expect(session.loading()).toBe(false); inFlight.resolve(result("TRUE")); await inFlight.promise; expect(session.results()).toBeUndefined(); + expect(session.loading()).toBe(false); expect(session.stale()).toBe(true); expect(onResults).not.toHaveBeenCalled(); }); @@ -153,16 +155,16 @@ describe("screening session", () => { .mockResolvedValueOnce(result("FALSE")); const { session } = await start({ evaluate, onEvaluationFailed }); - type("Answer", "first"); + answer("Answer", "first"); await until(() => expect(session.results()).toEqual(result("TRUE"))); - type("Answer", "second"); + answer("Answer", "second"); await until(() => expect(session.failed()).toBe(true)); expect(session.results()).toEqual(result("TRUE")); expect(session.stale()).toBe(true); expect(session.loading()).toBe(false); expect(onEvaluationFailed).toHaveBeenCalledOnce(); - type("Answer", "third"); + answer("Answer", "third"); await until(() => expect(session.results()).toEqual(result("FALSE"))); expect(session.failed()).toBe(false); }); @@ -177,9 +179,9 @@ describe("screening session", () => { .mockResolvedValueOnce(result("TRUE")); const { session } = await start({ evaluate, onEvaluationFailed }); - type("Answer", "older"); + answer("Answer", "older"); await until(() => expect(evaluate).toHaveBeenCalledOnce()); - type("Answer", "newer"); + answer("Answer", "newer"); older.reject(new Error("Unavailable")); await until(() => expect(session.results()).toEqual(result("TRUE"))); @@ -191,7 +193,7 @@ describe("screening session", () => { const evaluate = vi.fn(async () => result("FALSE", ["income"])); const { session } = await start({ evaluate }); - type("Answer", "kept"); + answer("Answer", "kept"); await until(() => expect(input("Income")).toBeUndefined()); expect(session.unneededQuestionCount()).toBe(1); expect(input("Answer")!.value).toBe("kept"); @@ -207,7 +209,7 @@ describe("screening session", () => { const evaluate = vi.fn(async () => result("TRUE")); const { setSchema } = await start({ evaluate }); - type("Answer", "kept"); + answer("Answer", "kept"); setSchema({ ...schema, components: [ @@ -237,7 +239,7 @@ describe("screening session", () => { expect.objectContaining({ married: null }), ); - type("Answer", "Edited"); + answer("Answer", "Edited"); await until(() => expect(evaluate).toHaveBeenCalledTimes(2)); expect(evaluate).toHaveBeenLastCalledWith( expect.objectContaining({ answer: "Edited", married: null }), @@ -294,7 +296,7 @@ describe("screening session", () => { const evaluate = vi.fn(() => inFlight.promise); const first = await start({ evaluate, onResults }); - type("Answer", "sent"); + answer("Answer", "sent"); await until(() => expect(evaluate).toHaveBeenCalledOnce()); first.dispose(); inFlight.resolve(result("TRUE")); @@ -303,7 +305,7 @@ describe("screening session", () => { expect(first.session.results()).toBeUndefined(); const second = await start({ evaluate }); - type("Answer", "never sent"); + answer("Answer", "never sent"); second.dispose(); await new Promise((resolve) => setTimeout(resolve, 1000)); expect(evaluate).toHaveBeenCalledOnce(); @@ -374,7 +376,7 @@ describe("next steps for CRM hosts", () => { "Shown group", ); - type("City", "Philadelphia"); + answer("City", "Philadelphia"); await until(() => expect(projected).toHaveLength(1)); expect(input("City")).toBeUndefined(); expect(projected[0]).toEqual(session.projectNextSteps(decided)); diff --git a/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx b/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx index 630d1ff3..4b61f9c9 100644 --- a/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx +++ b/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx @@ -221,6 +221,8 @@ export function createScreeningSession( unknownPaths = keepUnansweredPaths(unknownPaths, event.data); latestEvaluation++; setStale(true); + // Nothing is evaluating the new answers until the delay ends. + setLoading(false); debouncedSubmit(event.data); }); }) diff --git a/builder-frontend/src/testing/formJs.ts b/builder-frontend/src/testing/formJs.ts index c97fe1b5..eb722754 100644 --- a/builder-frontend/src/testing/formJs.ts +++ b/builder-frontend/src/testing/formJs.ts @@ -7,17 +7,15 @@ export const input = (label: string) => element.textContent?.trim().startsWith(label), )?.control as HTMLInputElement | null | undefined; -// form-js renders once more just after an import, resetting what was typed before it. -export const settle = () => new Promise((resolve) => setTimeout(resolve, 100)); - // The session waits for answers to stop changing. export const until = (assertion: () => void) => vi.waitFor(assertion, { timeout: 3000 }); -export function type(label: string, value: string) { +export function answer(label: string, value: string) { const field = input(label)!; field.value = value; field.dispatchEvent(new Event("input", { bubbles: true })); - // Leaving the field hands the answer to the form without form-js's typing delay. + // Leaving the field hands the answer to the form at once, before form-js's typing + // delay ends or its redraw after an import can reset the field. field.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); } From 6e38f770d850e7a384cfab6f538ad30351e3a8c5 Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Thu, 5 Nov 2026 15:24:05 -0500 Subject: [PATCH 2/7] Remove leftovers from the screening session move - Drop NextStepsProvider, unused since the session projects next steps. - Type Preview's answers as ScreeningAnswers and remove the duplicate PreviewFormData declarations. - Destructure screenerName with the session's other options. - Lower the type-check baseline for the deleted preview FormRenderer. Co-Authored-By: Claude Opus 5.5 --- .../src/components/screenerEditor/preview/Results.tsx | 6 +++--- .../src/components/screenerEditor/preview/types.ts | 6 ------ .../components/shared/screeningSession/screeningSession.tsx | 3 ++- builder-frontend/src/integrations/nextSteps.ts | 3 --- builder-frontend/src/types.ts | 5 ----- builder-frontend/typecheck-baseline.json | 1 - 6 files changed, 5 insertions(+), 19 deletions(-) delete mode 100644 builder-frontend/src/components/screenerEditor/preview/types.ts diff --git a/builder-frontend/src/components/screenerEditor/preview/Results.tsx b/builder-frontend/src/components/screenerEditor/preview/Results.tsx index 281659b5..fd6e6b01 100644 --- a/builder-frontend/src/components/screenerEditor/preview/Results.tsx +++ b/builder-frontend/src/components/screenerEditor/preview/Results.tsx @@ -1,7 +1,7 @@ import { Accessor, For, Match, Show, Switch } from "solid-js"; -import { PreviewFormData, ScreenerResult } from "./types"; -import type { ParameterValues } from "@/types"; +import type { ParameterValues, ScreenerResult } from "@/types"; +import type { ScreeningAnswers } from "@/components/shared/screeningSession/screeningSession"; import { checkDisplayName } from "@/utils/checkDisplayName"; import { createReconciledResults } from "@/utils/reconciledResults"; @@ -30,7 +30,7 @@ export default function Results({ results, resultsLoading, }: { - inputData: Accessor; + inputData: Accessor; results: Accessor; resultsLoading: Accessor; }) { diff --git a/builder-frontend/src/components/screenerEditor/preview/types.ts b/builder-frontend/src/components/screenerEditor/preview/types.ts deleted file mode 100644 index 829af2d5..00000000 --- a/builder-frontend/src/components/screenerEditor/preview/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type { ScreenerResult } from "@/types"; - -/* Form Data for Preview */ -export interface PreviewFormData { - [key: string]: any; -} diff --git a/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx b/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx index 4b61f9c9..df7a5c62 100644 --- a/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx +++ b/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx @@ -91,6 +91,7 @@ export function createScreeningSession( evaluate, initialAnswers, evaluateOnLoad, + screenerName, onResults, onEvaluationFailed, } = options; @@ -191,7 +192,7 @@ export function createScreeningSession( schema, evaluatedAnswers: answersSent, results, - screenerName: options.screenerName, + screenerName, pending: stale, failed, onNextStepsShownChange: setNextStepsShown, diff --git a/builder-frontend/src/integrations/nextSteps.ts b/builder-frontend/src/integrations/nextSteps.ts index 86edfde5..e36dc78d 100644 --- a/builder-frontend/src/integrations/nextSteps.ts +++ b/builder-frontend/src/integrations/nextSteps.ts @@ -7,9 +7,6 @@ import { } from "@/components/screenerEditor/formJsExtensions/nextSteps/content"; export type CrmNextSteps = Record; -export type NextStepsProvider = ( - results: ScreenerResult, -) => CrmNextSteps | undefined; /** Project the saved screener copy, never evaluation-provider content or generated HTML. */ export function projectNextSteps( diff --git a/builder-frontend/src/types.ts b/builder-frontend/src/types.ts index e19197f5..12feeffa 100644 --- a/builder-frontend/src/types.ts +++ b/builder-frontend/src/types.ts @@ -148,11 +148,6 @@ export interface CheckResult { } export type OptionalBoolean = "TRUE" | "FALSE" | "UNABLE_TO_DETERMINE"; -/* Form Data for Preview */ -export interface PreviewFormData { - [key: string]: any; -} - // Published Screener Types export interface PublishedScreener { screenerName: string; diff --git a/builder-frontend/typecheck-baseline.json b/builder-frontend/typecheck-baseline.json index d0ce1c96..105139a2 100644 --- a/builder-frontend/typecheck-baseline.json +++ b/builder-frontend/typecheck-baseline.json @@ -25,7 +25,6 @@ "src/components/screenerEditor/manageBenefits/configureBenefit/SelectedEligibilityCheck.tsx": 1, "src/components/screenerEditor/manageBenefits/configureBenefit/benefitResource.ts": 2, "src/components/screenerEditor/manageBenefits/configureBenefit/modals/EditExplanationModal.test.tsx": 1, - "src/components/screenerEditor/preview/FormRenderer.tsx": 1, "src/components/screenerEditor/preview/Preview.tsx": 2, "src/components/shared/CheckExplanation.tsx": 1, "src/components/shared/ComponentLibrary.tsx": 1, From 3a2159ba4da236910b54f1086bc43f18a5ca6e51 Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Thu, 5 Nov 2026 15:35:01 -0500 Subject: [PATCH 3/7] Clear an evaluation failure when the person edits an answer The failure was about answers that are no longer the latest, so Preview and the published screener stop reporting it while the new answers wait to be evaluated. Co-Authored-By: Claude Opus 5.5 --- .../shared/screeningSession/screeningSession.test.tsx | 3 ++- .../components/shared/screeningSession/screeningSession.tsx | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx b/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx index 37842c50..5c282d58 100644 --- a/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx +++ b/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx @@ -145,7 +145,7 @@ describe("screening session", () => { expect(onResults).not.toHaveBeenCalled(); }); - it("keeps the last results when an evaluation fails, until one succeeds", async () => { + it("keeps the last results when an evaluation fails, until one succeeds, and clears the failure on an edit", async () => { vi.spyOn(console, "error").mockImplementation(() => {}); const onEvaluationFailed = vi.fn(); const evaluate = vi @@ -165,6 +165,7 @@ describe("screening session", () => { expect(onEvaluationFailed).toHaveBeenCalledOnce(); answer("Answer", "third"); + expect(session.failed()).toBe(false); await until(() => expect(session.results()).toEqual(result("FALSE"))); expect(session.failed()).toBe(false); }); diff --git a/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx b/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx index df7a5c62..03119e14 100644 --- a/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx +++ b/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx @@ -66,6 +66,7 @@ export interface ScreeningSession { results: Accessor; /** True from an edit until results for the latest answers arrive. */ stale: Accessor; + /** True once evaluating the latest answers failed, until the next edit. */ failed: Accessor; /** True only while the latest answers are being evaluated, not while waiting to send them. */ loading: Accessor; @@ -222,8 +223,10 @@ export function createScreeningSession( unknownPaths = keepUnansweredPaths(unknownPaths, event.data); latestEvaluation++; setStale(true); - // Nothing is evaluating the new answers until the delay ends. + // Nothing is evaluating the new answers until the delay ends, and + // no evaluation of them has failed. setLoading(false); + setFailed(false); debouncedSubmit(event.data); }); }) From 3748280fc876af640d0afc7d3bd6a35f6b61ce6f Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Thu, 5 Nov 2026 15:35:19 -0500 Subject: [PATCH 4/7] Mark Preview results as outdated from the moment an answer changes Results took the session's loading flag, which an edit now clears until the answers are sent. For half a second the pane showed old benefit rows with no indicator, or went blank while the first results were pending. Drive the indicator from stale instead, and hide it once an evaluation fails, since the failure alert covers that state. Co-Authored-By: Claude Opus 5.5 --- .../src/components/screenerEditor/preview/Preview.test.tsx | 4 ++++ .../src/components/screenerEditor/preview/Preview.tsx | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/builder-frontend/src/components/screenerEditor/preview/Preview.test.tsx b/builder-frontend/src/components/screenerEditor/preview/Preview.test.tsx index 01df3046..67dab7c0 100644 --- a/builder-frontend/src/components/screenerEditor/preview/Preview.test.tsx +++ b/builder-frontend/src/components/screenerEditor/preview/Preview.test.tsx @@ -73,10 +73,14 @@ it("evaluates the working screener on open and keeps the last results when an ev expect.objectContaining({ answer: "first" }), ); + expect(previewResults.props.resultsLoading()).toBe(false); + answer("Answer", "second"); + expect(previewResults.props.resultsLoading()).toBe(true); await until(() => expect(container.querySelector('[role="alert"]')).not.toBeNull(), ); expect(container.querySelector('[role="status"]')).toBeNull(); expect(previewResults.props.results()).toEqual(decided); + expect(previewResults.props.resultsLoading()).toBe(false); }); diff --git a/builder-frontend/src/components/screenerEditor/preview/Preview.tsx b/builder-frontend/src/components/screenerEditor/preview/Preview.tsx index 5fa8ebdf..7f8e9fbb 100644 --- a/builder-frontend/src/components/screenerEditor/preview/Preview.tsx +++ b/builder-frontend/src/components/screenerEditor/preview/Preview.tsx @@ -76,7 +76,9 @@ const Preview = ({ screener, formSchema }) => { session.stale() && !session.failed()} /> From 343bdff3559dec52da33f387083331cc8508a0f8 Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Thu, 5 Nov 2026 15:35:54 -0500 Subject: [PATCH 5/7] Ignore only form-js's own import changes while re-importing the form The importing flag stayed set until the awaited import resumed, so an edit landing in that window would update the answers without marking them stale or scheduling an evaluation. form-js imports synchronously, so clear the flag as soon as importSchema returns. Co-Authored-By: Claude Opus 5.5 --- .../components/shared/screeningSession/screeningSession.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx b/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx index 03119e14..6fa5217a 100644 --- a/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx +++ b/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx @@ -161,17 +161,21 @@ export function createScreeningSession( const importVisibleSchema = async () => { if (!form) return; + // form-js imports synchronously, so clearing this before awaiting ignores + // only the import's own changes, never an edit made while it settles. importing = true; + let done: Promise; try { // Preserve the live form state, not the last submitted data: answers // typed since the pending submit would otherwise be discarded. - await form.importSchema( + done = form.importSchema( hideQuestions(schema(), hiddenQuestionPaths()), cloneDeep(currentData), ); } finally { importing = false; } + await done; }; createEffect( From 468d365d5cc2459db0a3a8c3b6d9248b53694deb Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Thu, 5 Nov 2026 15:36:26 -0500 Subject: [PATCH 6/7] Keep ScreeningAnswers with the other shared types Results imported it from the screening session module, which also pulls in form-js and its CSS, just for a type. Define it in @/types beside ScreenerResult. Co-Authored-By: Claude Opus 5.5 --- builder-frontend/src/components/screener/Screener.tsx | 7 ++----- .../src/components/screenerEditor/preview/Results.tsx | 7 +++++-- .../shared/screeningSession/screeningSession.test.tsx | 3 +-- .../shared/screeningSession/screeningSession.tsx | 4 +--- builder-frontend/src/types.ts | 3 +++ 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/builder-frontend/src/components/screener/Screener.tsx b/builder-frontend/src/components/screener/Screener.tsx index c009b7c4..2748f144 100644 --- a/builder-frontend/src/components/screener/Screener.tsx +++ b/builder-frontend/src/components/screener/Screener.tsx @@ -17,12 +17,9 @@ import { import HiddenQuestionsNotice from "@/components/shared/HiddenQuestionsNotice"; import ScreeningComplete from "@/components/shared/ScreeningComplete"; -import { - createScreeningSession, - type ScreeningAnswers, -} from "@/components/shared/screeningSession/screeningSession"; +import { createScreeningSession } from "@/components/shared/screeningSession/screeningSession"; -import type { PublishedScreener } from "@/types"; +import type { PublishedScreener, ScreeningAnswers } from "@/types"; import { createScreeningBridge, type ScreeningBridge, diff --git a/builder-frontend/src/components/screenerEditor/preview/Results.tsx b/builder-frontend/src/components/screenerEditor/preview/Results.tsx index fd6e6b01..8e6c8cee 100644 --- a/builder-frontend/src/components/screenerEditor/preview/Results.tsx +++ b/builder-frontend/src/components/screenerEditor/preview/Results.tsx @@ -1,7 +1,10 @@ import { Accessor, For, Match, Show, Switch } from "solid-js"; -import type { ParameterValues, ScreenerResult } from "@/types"; -import type { ScreeningAnswers } from "@/components/shared/screeningSession/screeningSession"; +import type { + ParameterValues, + ScreenerResult, + ScreeningAnswers, +} from "@/types"; import { checkDisplayName } from "@/utils/checkDisplayName"; import { createReconciledResults } from "@/utils/reconciledResults"; diff --git a/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx b/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx index 5c282d58..2ed44d5e 100644 --- a/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx +++ b/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx @@ -3,10 +3,9 @@ import { createRoot, createSignal } from "solid-js"; import { insert } from "solid-js/web"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ScreenerResult } from "@/types"; +import type { ScreenerResult, ScreeningAnswers } from "@/types"; import { createScreeningSession, - type ScreeningAnswers, type ScreeningSessionOptions, } from "./screeningSession"; import { answer, input, until } from "@/testing/formJs"; diff --git a/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx b/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx index 6fa5217a..f56463c8 100644 --- a/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx +++ b/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx @@ -24,7 +24,7 @@ import { projectNextSteps as projectFieldNextSteps, type CrmNextSteps, } from "@/integrations/nextSteps"; -import type { ScreenerResult } from "@/types"; +import type { ScreenerResult, ScreeningAnswers } from "@/types"; import { normalizeArrayFieldData } from "@/utils/arrayFieldData"; import { getUnneededQuestionPaths, @@ -39,8 +39,6 @@ import { import "@bpmn-io/form-js/dist/assets/form-js.css"; -export type ScreeningAnswers = Record; - /** How long answers must stay unchanged before they are evaluated. */ export const EVALUATION_DELAY_MS = 500; diff --git a/builder-frontend/src/types.ts b/builder-frontend/src/types.ts index 12feeffa..87f3dac1 100644 --- a/builder-frontend/src/types.ts +++ b/builder-frontend/src/types.ts @@ -121,6 +121,9 @@ export type ParameterDefinition = { required: boolean; }; +/* Answers a person gives a screener, keyed by form field path */ +export type ScreeningAnswers = Record; + /* Screener Evaluation Results */ export interface ScreenerResult { [key: string]: BenefitResult; From 3dd52500bd0f766dc1bce21080c9476ccafafbb8 Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Thu, 5 Nov 2026 15:37:00 -0500 Subject: [PATCH 7/7] Wait for form-js fields to render, and name a missing one toBeDefined() passes when input() finds the label but no control, so several waits could end before the field rendered. Wait for a truthy field instead, and have answer() name the label it could not find rather than failing with a TypeError on null. Co-Authored-By: Claude Opus 5.5 --- .../src/components/screener/Screener.test.tsx | 8 ++++---- .../components/screenerEditor/preview/Preview.test.tsx | 2 +- .../shared/screeningSession/screeningSession.test.tsx | 4 ++-- builder-frontend/src/testing/formJs.ts | 3 ++- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/builder-frontend/src/components/screener/Screener.test.tsx b/builder-frontend/src/components/screener/Screener.test.tsx index ae05e7e2..fe8c4959 100644 --- a/builder-frontend/src/components/screener/Screener.test.tsx +++ b/builder-frontend/src/components/screener/Screener.test.tsx @@ -172,7 +172,7 @@ describe("published screener integration lifecycle", () => { expect(integration.fail).toHaveBeenCalledExactlyOnceWith( "ORIGIN_NOT_ALLOWED", ); - await until(() => expect(input("City")).toBeDefined()); + await until(() => expect(input("City")).toBeTruthy()); expect(container.textContent).toContain( "isn’t set up to send results to https://crm.example", ); @@ -187,7 +187,7 @@ describe("published screener integration lifecycle", () => { dispose = render(() => , container); await vi.waitFor(() => expect(integration.start).toHaveBeenCalledOnce()); integration.disconnect!("timeout"); - await until(() => expect(input("City")).toBeDefined()); + await until(() => expect(input("City")).toBeTruthy()); expect(container.textContent).not.toContain("Waiting for information"); expect(container.textContent).toContain("couldn’t connect"); expect(evaluatePublishedScreener).not.toHaveBeenCalled(); @@ -204,7 +204,7 @@ describe("published screener integration lifecycle", () => { await vi.waitFor(() => expect(container.textContent).toContain("no longer connected"), ); - expect(input("City")).toBeDefined(); + expect(input("City")).toBeTruthy(); expect(evaluatePublishedScreener).toHaveBeenCalledOnce(); }); @@ -212,7 +212,7 @@ describe("published screener integration lifecycle", () => { integration.enabled = false; vi.mocked(evaluatePublishedScreener).mockResolvedValue(result("TRUE")); dispose = render(() => , container); - await until(() => expect(input("City")).toBeDefined()); + await until(() => expect(input("City")).toBeTruthy()); expect(container.textContent).not.toContain("Waiting for information"); expect(evaluatePublishedScreener).not.toHaveBeenCalled(); answer("City", "Pittsburgh"); diff --git a/builder-frontend/src/components/screenerEditor/preview/Preview.test.tsx b/builder-frontend/src/components/screenerEditor/preview/Preview.test.tsx index 67dab7c0..acebf87c 100644 --- a/builder-frontend/src/components/screenerEditor/preview/Preview.test.tsx +++ b/builder-frontend/src/components/screenerEditor/preview/Preview.test.tsx @@ -50,7 +50,7 @@ it("evaluates the working screener on open and keeps the last results when an ev ), container, ); - await until(() => expect(input("Answer")).toBeDefined()); + await until(() => expect(input("Answer")).toBeTruthy()); await until(() => expect(evaluateScreener).toHaveBeenCalledExactlyOnceWith("working", { answer: "", diff --git a/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx b/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx index 2ed44d5e..2b7b3949 100644 --- a/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx +++ b/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx @@ -199,7 +199,7 @@ describe("screening session", () => { expect(input("Answer")!.value).toBe("kept"); session.toggleShowAllQuestions(); - await until(() => expect(input("Income")).toBeDefined()); + await until(() => expect(input("Income")).toBeTruthy()); expect(session.showAllQuestions()).toBe(true); expect(input("Answer")!.value).toBe("kept"); expect(evaluate).toHaveBeenCalledOnce(); @@ -217,7 +217,7 @@ describe("screening session", () => { { id: "age", type: "number", key: "age", label: "Age" }, ], }); - await until(() => expect(input("Age")).toBeDefined()); + await until(() => expect(input("Age")).toBeTruthy()); expect(input("Answer")!.value).toBe("kept"); }); diff --git a/builder-frontend/src/testing/formJs.ts b/builder-frontend/src/testing/formJs.ts index eb722754..e7f05065 100644 --- a/builder-frontend/src/testing/formJs.ts +++ b/builder-frontend/src/testing/formJs.ts @@ -12,7 +12,8 @@ export const until = (assertion: () => void) => vi.waitFor(assertion, { timeout: 3000 }); export function answer(label: string, value: string) { - const field = input(label)!; + const field = input(label); + if (!field) throw new Error(`No form field labelled ${label}`); field.value = value; field.dispatchEvent(new Event("input", { bubbles: true })); // Leaving the field hands the answer to the form at once, before form-js's typing