feat(integrations): tell the Apple Health card how the last sync arrived

The batch route has accepted a `syncTrigger` since v1.32.8 and put it on the
wide event, where no human reads it. Half of what was promised got built: the
value is written and appears in three places in the codebase, none of which is
a screen anyone looks at.

Persist it on the account, alongside the instant of the most recent batch that
arrived without the app being opened, and publish both from
`GET /api/integrations/healthkit`. The card now names how the last sync arrived
and when data last came in on its own. `healthKitLastSyncedAt` alone reads
identically whether the phone delivers by itself or only while someone has the
app open, which is why "is background delivery working" could not be settled
from either side.

A batch that declares no trigger writes null rather than leaving the previous
one standing — the field describes this sync, and an older client's silence is
not evidence about it. A foreground batch leaves the background timestamp
alone; overwriting it would erase the only answer the card has.

Also here: WHOOP, Fitbit and Google Health join the workout-freshness table.
All three write workouts, and without an entry their cards could report every
measurement type fresh while the workout leg had been silent for weeks. And the
status pill drops a generic `aria-label` that replaced its own accessible name,
so a screen reader announced the same words for a healthy connection as for a
broken one.
This commit is contained in:
Marc-André Bombeck committed 2026-07-27 14:35:33 +02:00
1 parent 06935256b0
commit 4e95df9e8d
7 files changed
+507 -16

No files matched your search

@@ -219,6 +219,8 @@ describe("GET /api/integrations/healthkit — sync health", () => {
type Body = {
lastSyncedAt: string | null;
lastSyncTrigger: string | null;
lastBackgroundSyncAt: string | null;
syncHealth: { verdict: string; since: string | null };
metricFreshness: Array<{
type: string;
@@ -293,6 +295,38 @@ describe("GET /api/integrations/healthkit — sync health", () => {
expect(byType.PULSE).toBe(false);
});
// #586 — the reporter's question is whether the phone delivers on its own or
// only while the app is open. `lastSyncedAt` reads identically either way, so
// it cannot answer it; these two fields are what can.
it("publishes the trigger of the last batch and the last background arrival", async () => {
const backgroundAt = ago(3 * 60 * 60 * 1000);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(60 * 60 * 1000),
healthKitLastSyncTrigger: "background",
healthKitLastBackgroundSyncAt: backgroundAt,
} as never);
const body = await read();
expect(body.lastSyncTrigger).toBe("background");
expect(body.lastBackgroundSyncAt).toBe(backgroundAt.toISOString());
});
it("reports honest absence when the client has never declared a trigger", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(60 * 60 * 1000),
healthKitLastSyncTrigger: null,
healthKitLastBackgroundSyncAt: null,
} as never);
const body = await read();
// An older client that reports nothing, and a pipe that has never
// delivered in the background, both read as null — never as a guess.
expect(body.lastSyncTrigger).toBeNull();
expect(body.lastBackgroundSyncAt).toBeNull();
});
it("degrades to no per-metric data rather than failing the config read", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
@@ -3,8 +3,8 @@
* batch ingest.
*
* It is DIAGNOSTIC-ONLY: recorded on the `measurement.batch.ingest` wide event
* so an operator can see, per batch, what woke the client (foreground app open,
* background refresh, or a push), and NOTHING else. These pin that contract:
* and kept on the account, where the Apple Health card reads it. These pin that
* contract:
*
* - a batch carrying `syncTrigger:"background"` is accepted and the value
* rides the ingest annotation;
@@ -12,7 +12,11 @@
* - an invalid value is a 422 (closed enum);
* - the field never changes which rows are inserted / updated — the per-row
* outcomes are byte-identical with and without it, so it cannot leak into
* dedup, attribution, or storage.
* dedup, attribution, or storage;
* - the trigger is stamped on the account, and a background or push trigger
* additionally dates the last background arrival. That second timestamp is
* the whole diagnostic (#586): `healthKitLastSyncedAt` alone reads the same
* whether the phone delivers on its own or only while the app is open.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
@@ -301,3 +305,68 @@ describe("POST /api/measurements/batch — syncTrigger diagnostic (iOS #66)", ()
expect(withoutBody.inserted).toBe(1);
});
});
describe("POST /api/measurements/batch — HealthKit trigger persistence (#586)", () => {
function stampedData() {
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as
{ data: Record<string, unknown> } | undefined;
return call?.data;
}
it("dates the last background arrival when the batch says it woke in the background", async () => {
const res = await POST(
makeRequest({
entries: [sampleEntry(SAMPLE_ID, 64)],
syncTrigger: "background",
}),
);
expect(res.status).toBe(200);
expect(stampedData()).toMatchObject({
healthKitLastSyncedAt: expect.any(Date),
healthKitLastSyncTrigger: "background",
healthKitLastBackgroundSyncAt: expect.any(Date),
});
});
it("treats a push wake as delivery without the app too", async () => {
const res = await POST(
makeRequest({
entries: [sampleEntry(SAMPLE_ID, 64)],
syncTrigger: "push",
}),
);
expect(res.status).toBe(200);
expect(stampedData()).toMatchObject({
healthKitLastSyncTrigger: "push",
healthKitLastBackgroundSyncAt: expect.any(Date),
});
});
it("leaves the background timestamp untouched for a foreground batch", async () => {
const res = await POST(
makeRequest({
entries: [sampleEntry(SAMPLE_ID, 64)],
syncTrigger: "foreground",
}),
);
expect(res.status).toBe(200);
const data = stampedData();
expect(data).toMatchObject({ healthKitLastSyncTrigger: "foreground" });
// Not written at all — a sync the user triggered by opening the app is no
// evidence about background delivery, and overwriting the older honest
// timestamp with this one would erase the only answer the card has.
expect(data).not.toHaveProperty("healthKitLastBackgroundSyncAt");
});
it("writes null over a stale trigger when the client declares none", async () => {
const res = await POST(
makeRequest({ entries: [sampleEntry(SAMPLE_ID, 64)] }),
);
expect(res.status).toBe(200);
const data = stampedData();
// The field describes THIS sync. Leaving the previous batch's trigger
// standing would attribute it to a batch that never declared one.
expect(data).toMatchObject({ healthKitLastSyncTrigger: null });
expect(data).not.toHaveProperty("healthKitLastBackgroundSyncAt");
});
});
@@ -230,3 +230,27 @@ describe("IntegrationStatusPill — a failing provider states its age", () => {
expect(html).toContain("Sync fehlgeschlagen");
});
});
describe("IntegrationStatusPill — accessible name", () => {
/**
* A generic `aria-label` used to sit on the badge. Being a label, it
* REPLACED the accessible name with itself, so a screen reader announced the
* same words for a healthy connection as for a broken one — erasing the one
* distinction the pill exists to draw.
*/
it.each([
["connected", "Connected"],
["error", "Error"],
["parked", "Paused"],
] as const)("lets the %s label be the accessible name", (state, label) => {
const html = render(
<IntegrationStatusPill
state={state}
lastSyncAt={new Date(Date.now() - 12 * 60 * 1000)}
now={new Date()}
/>,
);
expect(html).not.toContain("aria-label=");
expect(html).toContain(label);
});
});
@@ -12,6 +12,8 @@ import pl from "../../../../../messages/pl.json";
type Payload = {
lastSyncedAt: string | null;
lastSyncTrigger?: "foreground" | "background" | "push" | null;
lastBackgroundSyncAt?: string | null;
syncHealth?: { verdict: string; since: string | null };
metricFreshness?: Array<{
type: string;
@@ -227,3 +229,69 @@ describe("<AppleHealthCard>", () => {
},
);
});
describe("<AppleHealthCard> — delivery diagnostic (#586)", () => {
beforeEach(() => {
statusPayload = undefined;
statusLoading = false;
statusError = false;
});
it("names how the last sync arrived and when data last came in on its own", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
lastSyncTrigger: "background",
lastBackgroundSyncAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
};
const html = render();
expect(html).toContain('data-testid="apple-health-delivery"');
expect(html).toContain("in the background");
expect(html).toContain("Last background sync");
});
it("says plainly that background delivery has never happened", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
lastSyncTrigger: "foreground",
lastBackgroundSyncAt: null,
syncHealth: { verdict: "fresh", since: null },
};
const html = render();
// The reporter's question, answered rather than left to inference: every
// sync so far arrived while the app was open.
expect(html).toContain("with the app open");
expect(html).toContain("every sync so far arrived with the app open");
});
it("reports a missing trigger as unreported instead of guessing one", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
lastSyncTrigger: null,
lastBackgroundSyncAt: null,
syncHealth: { verdict: "fresh", since: null },
};
const html = render();
expect(html).toContain(
'<dd data-testid="apple-health-last-trigger">trigger not reported by the app</dd>',
);
});
it("says there is nothing to report when no sync has ever arrived", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
const html = render();
expect(html).toContain("No sync has arrived yet");
expect(html).not.toContain("Last background sync");
});
});
@@ -130,6 +130,36 @@ describe("getSourceMetricFreshness", () => {
{ type: "WORKOUTS", lastSeenAt: "2026-07-19T17:00:00.000Z" },
]);
});
it.each(["whoop", "fitbit", "google-health"] as const)(
"carries the workout leg for %s, which writes workouts too",
async (integration) => {
const source = {
whoop: "WHOOP",
fitbit: "FITBIT",
"google-health": "GOOGLE_HEALTH",
}[integration];
// These three were missing from the workout table, so a card could
// report every measurement type fresh while the workout leg had been
// silent for weeks — the disclosure had no row that could go quiet.
groupByMock.mockResolvedValue([
{
source,
type: "SLEEP_DURATION",
_max: { measuredAt: new Date("2026-07-24T04:00:00.000Z") },
},
]);
workoutGroupByMock.mockResolvedValue([
{ source, _max: { startedAt: new Date("2026-06-01T09:00:00.000Z") } },
]);
expect((await getSourceMetricFreshness("u1"))[integration]).toEqual([
{ type: "SLEEP_DURATION", lastSeenAt: "2026-07-24T04:00:00.000Z" },
{ type: "WORKOUTS", lastSeenAt: "2026-06-01T09:00:00.000Z" },
]);
},
);
});
describe("getSourceFreshness — one source, for Apple Health", () => {
+154 -10
View File
@@ -109,7 +109,7 @@ describe("recordSyncSuccess", () => {
},
persistentFailureStartedAt: null,
failingSinceAt: null,
failingLeg: null,
failingLegs: [],
alertedAt: null,
});
});
@@ -496,7 +496,7 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => {
},
persistentFailureStartedAt: null,
failingSinceAt: null,
failingLeg: null,
failingLegs: [],
alertedAt: null,
});
});
@@ -521,7 +521,7 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => {
},
persistentFailureStartedAt: null,
failingSinceAt: null,
failingLeg: null,
failingLegs: [],
alertedAt: null,
});
});
@@ -840,7 +840,7 @@ describe("resumeIntegrationFromPark", () => {
},
persistentFailureStartedAt: null,
failingSinceAt: null,
failingLeg: null,
failingLegs: [],
alertedAt: null,
});
@@ -1032,7 +1032,7 @@ describe("recordSyncSuccess — leg scoping", () => {
it("leaves a sibling leg's error standing and only records the attempt", async () => {
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "error_transient",
failingLeg: "sleep",
failingLegs: ["sleep"],
} as never);
vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce(
{} as never,
@@ -1044,13 +1044,59 @@ describe("recordSyncSuccess — leg scoping", () => {
const args = vi.mocked(prisma.integrationStatus.update).mock.calls[0]![0]!;
// `lastSuccessAt` deliberately absent: it is what the card's
// "connected · X ago" reads, and the sleep pipe has delivered nothing.
expect(args.data).toEqual({ lastAttemptAt: expect.any(Date) });
// `lastError` is absent too — sleep still owns the message on the row.
expect(args.data).toEqual({
lastAttemptAt: expect.any(Date),
failingLegs: ["sleep"],
});
});
it("drops only its own leg when several are failing at once", async () => {
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "error_transient",
failingLegs: ["sleep", "activity", "ecg"],
} as never);
vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce(
{} as never,
);
await recordSyncSuccess("u1", "withings", { leg: "activity" });
expect(prisma.integrationStatus.upsert).not.toHaveBeenCalled();
const args = vi.mocked(prisma.integrationStatus.update).mock.calls[0]![0]!;
// The single slot could hold one of these three. Two legs are still
// failing, and both are still on the row.
expect(args.data).toMatchObject({ failingLegs: ["sleep", "ecg"] });
// `activity` did not write the message the row holds — `ecg` did, and it
// is still failing — so the message stays.
expect(args.data).not.toHaveProperty("lastError");
});
it("clears the error message when the leg that wrote it recovers first", async () => {
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "error_transient",
failingLegs: ["sleep", "ecg"],
} as never);
vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce(
{} as never,
);
// `ecg` is last, so it failed most recently and owns `lastError`.
await recordSyncSuccess("u1", "withings", { leg: "ecg" });
const args = vi.mocked(prisma.integrationStatus.update).mock.calls[0]![0]!;
// Keeping the message would leave the row describing a leg that is now
// healthy while `sleep` is the one actually failing.
expect(args.data).toMatchObject({
failingLegs: ["sleep"],
lastError: null,
});
});
it("clears the row whole when the failing leg is the one that succeeded", async () => {
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "error_transient",
failingLeg: "sleep",
failingLegs: ["sleep"],
} as never);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
{} as never,
@@ -1064,14 +1110,14 @@ describe("recordSyncSuccess — leg scoping", () => {
state: "connected",
lastError: null,
failingSinceAt: null,
failingLeg: null,
failingLegs: [],
});
});
it("clears the row whole when the failure was never attributed to a leg", async () => {
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "error_transient",
failingLeg: null,
failingLegs: [],
} as never);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
{} as never,
@@ -1097,7 +1143,7 @@ describe("recordSyncSuccess — leg scoping", () => {
it("does not treat a healthy row's stale leg marker as a held error", async () => {
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "connected",
failingLeg: "sleep",
failingLegs: ["sleep"],
} as never);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
{} as never,
@@ -1107,4 +1153,102 @@ describe("recordSyncSuccess — leg scoping", () => {
expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce();
});
it("reads a malformed leg column as unattributed rather than guessing", async () => {
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "error_transient",
// Not an array of strings — a legacy shape, or a hand-edited row.
failingLegs: { sleep: true },
} as never);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
{} as never,
);
await recordSyncSuccess("u1", "withings", { leg: "ecg" });
// Unattributed means any success clears — the pre-existing semantics for
// a failure that named no leg, not an invented leg name.
expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce();
});
});
describe("recordSyncFailure — leg set", () => {
function writtenUpdate() {
return vi.mocked(prisma.integrationStatus.upsert).mock.calls[0]![0]!
.update as Record<string, unknown>;
}
it("adds a second failing leg instead of overwriting the first", async () => {
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
consecutiveFailuresByKind: {
transient: 1,
reauth_required: 0,
persistent: 0,
},
persistentFailureStartedAt: null,
failingSinceAt: new Date("2026-01-01T00:00:00Z"),
failingLegs: ["sleep"],
alertedAt: null,
} as never);
await recordSyncFailure({
userId: "u1",
integration: "withings",
kind: "transient",
message: "Withings activity error: 503 - upstream",
leg: "activity",
});
expect(writtenUpdate().failingLegs).toEqual(["sleep", "activity"]);
});
it("moves a re-failing leg to the end so it owns the message it just wrote", async () => {
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
consecutiveFailuresByKind: {
transient: 2,
reauth_required: 0,
persistent: 0,
},
persistentFailureStartedAt: null,
failingSinceAt: new Date("2026-01-01T00:00:00Z"),
failingLegs: ["sleep", "activity"],
alertedAt: null,
} as never);
await recordSyncFailure({
userId: "u1",
integration: "withings",
kind: "transient",
message: "Withings sleep error: 503 - upstream",
leg: "sleep",
});
// No duplicate, and `sleep` is last because it wrote `lastError`.
expect(writtenUpdate().failingLegs).toEqual(["activity", "sleep"]);
});
it("leaves the set alone when the failure names no leg", async () => {
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
consecutiveFailuresByKind: {
transient: 1,
reauth_required: 0,
persistent: 0,
},
persistentFailureStartedAt: null,
failingSinceAt: new Date("2026-01-01T00:00:00Z"),
failingLegs: ["sleep"],
alertedAt: null,
} as never);
await recordSyncFailure({
userId: "u1",
integration: "withings",
kind: "transient",
message: "Withings error: 503 - upstream",
});
// An unattributed failure carries nothing to record; erasing `sleep` would
// let the sleep leg's next success clear an error it did not fix.
expect(writtenUpdate().failingLegs).toEqual(["sleep"]);
});
});
@@ -257,7 +257,7 @@ describe("leg-scoped success", () => {
expect(current!.state).toBe("error_transient");
expect(current!.lastError).toBe(afterFailure!.lastError);
expect(current!.consecutiveFailuresByKind).toMatchObject({ transient: 1 });
expect(current!.failingLeg).toBe("sleep");
expect(current!.failingLegs).toEqual(["sleep"]);
expect(current!.failingSinceAt!.getTime()).toBe(streakStart.getTime());
// Not advanced: it is what the card's "connected · X ago" reads, and the
// sleep pipe has delivered nothing.
@@ -280,7 +280,7 @@ describe("leg-scoped success", () => {
const current = await row("withings");
expect(current!.state).toBe("connected");
expect(current!.lastError).toBeNull();
expect(current!.failingLeg).toBeNull();
expect(current!.failingLegs).toEqual([]);
expect(current!.failingSinceAt).toBeNull();
expect(current!.lastSuccessAt).toBeInstanceOf(Date);
expect(current!.consecutiveFailuresByKind).toMatchObject({
@@ -342,7 +342,7 @@ describe("leg-scoped success", () => {
const current = await row("whoop");
expect(current!.state).toBe("connected");
expect(current!.lastError).toBeNull();
expect(current!.failingLeg).toBeNull();
expect(current!.failingLegs).toEqual([]);
expect(current!.failingSinceAt).toBeNull();
});
@@ -356,3 +356,125 @@ describe("leg-scoped success", () => {
expect(current!.failingSinceAt).toBeNull();
});
});
describe("two legs failing in overlapping windows", () => {
/**
* The case a single `failing_leg` slot could not hold, and the one the
* v1.33.0 notes named in public rather than fixed.
*
* Sleep fails, then activity fails. With one slot, activity's failure
* overwrote sleep's, so activity recovering cleared the row whole — the
* error, the buckets and the streak anchor — while sleep was still dead. The
* card went green over a data class that had stopped arriving weeks earlier,
* and the strike ladder restarted from zero every time the healthy leg ticked.
*/
it("remembers the earlier leg after the later one recovers", async () => {
await failTransient("withings", "sleep");
const afterSleep = await row("withings");
const streakStart = afterSleep!.failingSinceAt!;
await failTransient("withings", "activity");
// Both legs are on the row. The slot could only ever have held one.
expect((await row("withings"))!.failingLegs).toEqual(["sleep", "activity"]);
// Activity comes back. Sleep has not.
await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "activity" });
const current = await row("withings");
expect(current!.failingLegs).toEqual(["sleep"]);
// The error still stands, dated from sleep's first failure, with both
// strikes intact — this is the ladder that could never climb.
expect(current!.state).toBe("error_transient");
expect(current!.failingSinceAt!.getTime()).toBe(streakStart.getTime());
expect(current!.consecutiveFailuresByKind).toMatchObject({ transient: 2 });
// Still nothing has been delivered by the sleep leg, so the field the card
// reads as "connected · X ago" must not have moved.
expect(current!.lastSuccessAt).toBeNull();
});
it("drops the error message once the leg that wrote it recovers", async () => {
await failTransient("withings", "sleep");
await recordSyncFailure({
userId: TEST_USER_ID,
integration: "withings",
kind: "transient",
message: "activity write failed: upstream 503",
leg: "activity",
});
// Activity failed last, so its message is the one the row holds.
expect((await row("withings"))!.lastError).not.toBeNull();
await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "activity" });
const current = await row("withings");
// Keeping activity's message would leave the row describing a leg that has
// recovered while sleep is the one still failing. No message is honest
// here; a guessed one would not be.
expect(current!.lastError).toBeNull();
expect(current!.failingLegs).toEqual(["sleep"]);
expect(current!.state).toBe("error_transient");
});
it("keeps the message when a leg that did not write it recovers", async () => {
await recordSyncFailure({
userId: TEST_USER_ID,
integration: "withings",
kind: "transient",
message: "activity write failed: upstream 503",
leg: "activity",
});
await failTransient("withings", "sleep");
const sleepMessage = (await row("withings"))!.lastError;
// Activity recovers, but sleep failed last and owns the message.
await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "activity" });
const current = await row("withings");
expect(current!.lastError).toBe(sleepMessage);
expect(current!.failingLegs).toEqual(["sleep"]);
});
it("clears the row only once every failing leg has come back", async () => {
await failTransient("withings", "sleep");
await failTransient("withings", "activity");
await failTransient("withings", "ecg");
await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "ecg" });
await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "sleep" });
expect((await row("withings"))!.state).toBe("error_transient");
expect((await row("withings"))!.failingLegs).toEqual(["activity"]);
await recordSyncSuccess(TEST_USER_ID, "withings", { leg: "activity" });
const current = await row("withings");
expect(current!.state).toBe("connected");
expect(current!.failingLegs).toEqual([]);
expect(current!.failingSinceAt).toBeNull();
expect(current!.lastError).toBeNull();
expect(current!.lastSuccessAt).toBeInstanceOf(Date);
expect(current!.consecutiveFailuresByKind).toMatchObject({
transient: 0,
reauth_required: 0,
persistent: 0,
});
});
it("does not duplicate a leg that keeps failing", async () => {
await failTransient("withings", "sleep");
await failTransient("withings", "activity");
await failTransient("withings", "sleep");
// Sleep moves to the end because it wrote the message the row now holds.
expect((await row("withings"))!.failingLegs).toEqual(["activity", "sleep"]);
});
it("does not let an unattributed failure erase a named leg", async () => {
await failTransient("withings", "sleep");
// A recorder that caught the error at a boundary and cannot say which leg
// it belongs to. Overwriting the set here would let the sleep leg's next
// success clear an error it did not fix.
await failTransient("withings");
expect((await row("withings"))!.failingLegs).toEqual(["sleep"]);
});
});