mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
The panel paints before its status query answers, and every variant inside it is conditional, so an assertion that a variant is absent could pass on a question that had simply not been answered yet — which is how the journey for the unmeasured-provider case passed on a retry against the very defect it was written to catch. The panel now carries the variant it settled on, absent until it has settled, and the three journeys wait on that instead of racing it. The third journey covers the state a freshly deployed instance is in before its first AI call: no offer, and no claim of an outage either.
221 lines
8.1 KiB
TypeScript
221 lines
8.1 KiB
TypeScript
/**
|
|
* v1.38.19 — the AI offer at the end of the setup flow.
|
|
*
|
|
* The screen used to promise that insights "work for you right now" the
|
|
* moment the operator had configured a key. It never knew whether the key
|
|
* answered, and it never mentioned the consent receipt the egress actually
|
|
* needs — so the person who believed it met a `consent.ai.required` refusal
|
|
* on their first briefing. On 2026-09-11 the operator's own instance was
|
|
* answering HTTP 500 from its OAuth proxy while the screen kept promising.
|
|
*
|
|
* Three runs, one account, and the only difference between them is a row in
|
|
* `provider_health`:
|
|
*
|
|
* a success inside the freshness window → the offer, and one tap grants
|
|
* a dead credential inside its cooldown → "not answering", and no button
|
|
* no row at all → neither: the panel says nothing
|
|
*
|
|
* What is deliberately absent: any call to a provider. The flow's rule holds
|
|
* — nothing in it talks to one — and a probe here would bill the operator for
|
|
* every registration on the instance.
|
|
*
|
|
* Serial, and it restores the instance state it seeded: the tri-state folds
|
|
* across accounts by design, so its inputs are instance-wide.
|
|
*/
|
|
import {
|
|
E2E_SETUP_AI_OFFER,
|
|
SETUP_AI_OFFER_STORAGE_STATE_PATH,
|
|
} from "./setup/global-setup";
|
|
import {
|
|
clearAiConsent,
|
|
clearOperatorProvider,
|
|
clearSharedProviderResult,
|
|
seedOperatorProvider,
|
|
seedSharedProviderResult,
|
|
} from "./setup/ai-offer-fixture";
|
|
import { resetSetupFlow } from "./setup/setup-flow-fixture";
|
|
import { expect, test } from "./setup/test";
|
|
import {
|
|
answer,
|
|
answerEverything,
|
|
complete,
|
|
expectScreen,
|
|
} from "./setup-flow-helpers";
|
|
|
|
const OFFER = '[data-slot="onboarding-ai-offer"]';
|
|
const GRANT = '[data-slot="onboarding-ai-offer-grant"]';
|
|
const UNAVAILABLE = '[data-slot="onboarding-ai-unavailable"]';
|
|
const SHARED_KEY = '[data-slot="onboarding-ai-shared-key"]';
|
|
/** The panel once it knows which variant it is; absent while the status is in
|
|
* flight, so an assertion of absence cannot pass on an unanswered question. */
|
|
const SETTLED = '[data-slot="onboarding-ai-panel"][data-ai-state]';
|
|
|
|
/** Walk the account to the done screen through the API, then open it. */
|
|
async function openDoneScreen(page: import("@playwright/test").Page) {
|
|
await page.goto("/onboarding");
|
|
await answerEverything(page);
|
|
// Q6 applies because the answers name glucose and weight; the flow does not
|
|
// complete while a screen it owes is unanswered.
|
|
await answer(page, {
|
|
step: "units",
|
|
units: { glucoseUnit: "mmol/L", unitPreference: "metric" },
|
|
});
|
|
await complete(page);
|
|
await answer(page, { step: "first-result", status: "skipped" });
|
|
await page.goto("/onboarding/done");
|
|
await expectScreen(page, "done");
|
|
// The panel itself is unconditional; the variant inside it is not.
|
|
await expect(page.locator('[data-slot="onboarding-ai-panel"]')).toBeVisible();
|
|
}
|
|
|
|
// One account, one instance-wide ledger. Two workers driving this at once
|
|
// would each read the other's seed.
|
|
test.describe.configure({ mode: "serial" });
|
|
|
|
test.describe("the setup flow's AI offer follows the shared provider's health", () => {
|
|
test.use({ storageState: SETUP_AI_OFFER_STORAGE_STATE_PATH });
|
|
|
|
test.beforeEach(async () => {
|
|
await resetSetupFlow(E2E_SETUP_AI_OFFER.username);
|
|
await clearAiConsent(E2E_SETUP_AI_OFFER.username);
|
|
await seedOperatorProvider();
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await clearOperatorProvider();
|
|
await clearAiConsent(E2E_SETUP_AI_OFFER.username);
|
|
});
|
|
|
|
test("offers one tap when the shared provider worked recently, and the tap leaves a receipt", async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
await seedSharedProviderResult(E2E_SETUP_AI_OFFER.username, {
|
|
result: "ok",
|
|
okMinutesAgo: 30,
|
|
});
|
|
|
|
// No receipt before the tap — otherwise the assertion after it proves
|
|
// nothing about the button.
|
|
const before = await page.request.get(
|
|
"/api/consent/ai/latest?kind=ai_full",
|
|
);
|
|
expect(before.status()).toBe(200);
|
|
expect(
|
|
((await before.json()) as { data: { receipt: unknown } }).data.receipt,
|
|
).toBeNull();
|
|
|
|
await openDoneScreen(page);
|
|
|
|
await expect(page.locator(SETTLED)).toHaveAttribute(
|
|
"data-ai-state",
|
|
"offer",
|
|
{ timeout: 15_000 },
|
|
);
|
|
await expect(page.locator(OFFER)).toBeVisible();
|
|
await expect(page.locator(UNAVAILABLE)).toHaveCount(0);
|
|
|
|
// No provider was called to decide this. The flow reads a projection.
|
|
const probes: string[] = [];
|
|
page.on("request", (req) => {
|
|
const path = new URL(req.url()).pathname;
|
|
if (path === "/api/ai/test" || path.startsWith("/api/insights/"))
|
|
probes.push(path);
|
|
});
|
|
|
|
await page.locator(GRANT).click();
|
|
|
|
// The line the person reads swaps to the granted one.
|
|
await expect(page.locator(SHARED_KEY)).toBeVisible({ timeout: 15_000 });
|
|
await expect(page.locator(GRANT)).toHaveCount(0);
|
|
expect(probes, "the offer must not trigger a provider call").toEqual([]);
|
|
|
|
const after = await page.request.get("/api/consent/ai/latest?kind=ai_full");
|
|
const receipt = (
|
|
(await after.json()) as {
|
|
data: { receipt: { kind: string; revokedAt: string | null } | null };
|
|
}
|
|
).data.receipt;
|
|
expect(receipt).not.toBeNull();
|
|
expect(receipt!.kind).toBe("ai_full");
|
|
expect(receipt!.revokedAt).toBeNull();
|
|
});
|
|
|
|
test("says the provider is not answering while its credential is benched, and offers no button", async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
await seedSharedProviderResult(E2E_SETUP_AI_OFFER.username, {
|
|
result: "auth_failed",
|
|
});
|
|
|
|
await openDoneScreen(page);
|
|
|
|
await expect(page.locator(SETTLED)).toHaveAttribute(
|
|
"data-ai-state",
|
|
"unavailable",
|
|
{ timeout: 15_000 },
|
|
);
|
|
await expect(page.locator(UNAVAILABLE)).toBeVisible();
|
|
await expect(page.locator(GRANT)).toHaveCount(0);
|
|
await expect(page.locator(OFFER)).toHaveCount(0);
|
|
// The unproven promise is not shown to somebody who has not consented.
|
|
await expect(page.locator(SHARED_KEY)).toHaveCount(0);
|
|
|
|
// The rest of the panel is untouched: the flow stays complete without it.
|
|
await expect(
|
|
page.locator('[data-slot="onboarding-ai-keyless"]'),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.locator('[data-slot="onboarding-open-dashboard"]'),
|
|
).toBeVisible();
|
|
|
|
// The grant route itself stays open — it is the user's own consent act
|
|
// and is reachable from Settings at any time. What this case pins is the
|
|
// decision the screen was handed, so read it back from the source.
|
|
const status = await page.request.get("/api/user/ai-provider");
|
|
const data = (
|
|
(await status.json()) as {
|
|
data: { serverProviderHealth: string; serverProviderOffer: boolean };
|
|
}
|
|
).data;
|
|
expect(data.serverProviderHealth).toBe("unhealthy");
|
|
expect(data.serverProviderOffer).toBe(false);
|
|
});
|
|
|
|
test("claims nothing on an instance whose provider nobody has used yet", async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
// The operator has configured a key and nothing has been asked of it —
|
|
// the state of every deployment until its first AI call, and so the most
|
|
// common way through this screen. The panel may not offer (nothing is
|
|
// known to work) and may not report an outage (nothing failed).
|
|
await clearSharedProviderResult();
|
|
|
|
await openDoneScreen(page);
|
|
|
|
await expect(page.locator(SETTLED)).toHaveAttribute(
|
|
"data-ai-state",
|
|
"neutral",
|
|
{ timeout: 15_000 },
|
|
);
|
|
await expect(
|
|
page.locator('[data-slot="onboarding-ai-keyless"]'),
|
|
).toBeVisible();
|
|
await expect(page.locator(UNAVAILABLE)).toHaveCount(0);
|
|
await expect(page.locator(OFFER)).toHaveCount(0);
|
|
await expect(page.locator(GRANT)).toHaveCount(0);
|
|
await expect(page.locator(SHARED_KEY)).toHaveCount(0);
|
|
|
|
const status = await page.request.get("/api/user/ai-provider");
|
|
const data = (
|
|
(await status.json()) as {
|
|
data: { serverProviderHealth: string; serverProviderOffer: boolean };
|
|
}
|
|
).data;
|
|
expect(data.serverProviderHealth).toBe("unknown");
|
|
expect(data.serverProviderOffer).toBe(false);
|
|
});
|
|
});
|