fix(cms): keep campaign touchpoints on home and off the wake path (#8269)

* fix(cms): host campaign touchpoints on the home view only

Every placement this app authorizes is a home placement (`opend.home.*`),
but the hosts did not follow. The modal pair mounted on every route except
onboarding, and the account badge rode the entry rail across all of its
tabs plus the project workbench corner — so a CMS campaign could open over
an open project, which is where a user reported meeting it.

The hosts now answer to the home view: App mounts the Test and Production
modals only there, EntryShell gates the badge and the hover entry on
`view === 'home'`, and the workbench cluster carries no CMS touchpoint at
all. The built-in DeepSeek pill is product chrome rather than a CMS host
and keeps its reach across entry tabs and project detail; the source
contract that guarded that reach is split to say so.

* fix(cms): stop a wake from re-presenting a displayed campaign

Screen sleep hides the page, which withdraws the lease and takes the modal
down. Waking refreshes — and the refresh carried the withdrawn lease as its
revalidation subject, which the impression gate read as "the active
activity" and exempted from the device impression. The server offers the
same activity, so the campaign re-opened on every wake, for as long as it
was published.

The exemption now belongs to the presentation still on screen and nothing
else. A page that goes hidden releases that presentation, so the offer
arriving on wake is a new one and the impression closes it. A suppressed
offer has to clear rather than retain: retaining republishes the very lease
the visibility fence just withdrew, which was how the first cut of this fix
kept the modal on screen. Retain stays for the case it was written for — an
offer arriving beside a live presentation, which keeps its mount.

* fix(cms): keep a displayed campaign through a recoverable poll failure

The lifecycle draws a line no other layer may redraw: only the server's own
withdrawal ends a live lease, and a transport failure rides out on the lease
already granted, retried inside the same cycle. The modal's `onError` ignored
that line and released its open presentation for any error at all.

That was harmless while the impression gate still exempted the active lease.
Keying the exemption on the presentation alone — which is what stopped a wake
from re-presenting a displayed campaign — made the two disagree: a poll that
failed on a dropped connection left the modal mounted with no presentation to
show for it, so the retry that recovered the very same activity read the device
impression, found nothing on screen, and cleared the host. The campaign
vanished mid-flight on ordinary network noise and could not return for the rest
of the run.

The presentation now follows the lease it belongs to, withdrawn only by a
withdrawal. The wake fence still releases it on hidden, because that
presentation is genuinely over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cms): retain presentation with active lease

* test(cms): await mounted presentation before refresh

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: ivy-ting <234137810+ivy-ting@users.noreply.github.com>
This commit is contained in:
Caprika
2026-09-19 11:28:37 +00:00
committed by GitHub
parent 053abdc1b7
commit f5707c8cae
8 changed files with 287 additions and 45 deletions
+5 -3
View File
@@ -5779,9 +5779,11 @@ function AppInner() {
dockLine
/>
)}
{/* Account restoration can finish while login/onboarding is still visible.
Keep campaign hosts out of that flow, independently of authentication. */}
{!(route.kind === 'home' && route.view === 'onboarding') && (
{/* Every placement these hosts may render is a home placement
(`opend.home.*`), so the home view is where they belong: not over a
project workbench, not over another entry tab, and — since account
restoration can finish while login is still up — not over onboarding. */}
{route.kind === 'home' && route.view === 'home' && (
<>
<TestCampaignModal
authenticated={isAmrSessionAuthenticated(amrLoginStatus)}
+11 -14
View File
@@ -128,7 +128,6 @@ import {
workspaceAnalyticsDimensions,
} from '../analytics/workspace';
import { WorkbenchCampaignBadge } from './WorkbenchCampaignBadge';
import { canRenderProductionCampaignBadge, ProductionCampaignBadge } from './ProductionCampaignBadge';
import { workspaceChromeAccountActionsHost } from './workspaceChromeActions';
/** Gap the account menu keeps from the rail card's top edge the same inset
@@ -1820,19 +1819,17 @@ export function WorkspaceTopRightAccountCluster({
context={context}
billing={billing}
balanceUsd={balanceUsd}
leadingSlot={campaignAudience || canRenderProductionCampaignBadge(amrLoggedIn === true, amrAccountId) ? (
<>
{campaignAudience ? (
<WorkbenchCampaignBadge
audience={campaignAudience}
page="project"
metricsConsent={metricsConsent}
installationId={installationId}
loggedIn={amrLoggedIn}
/>
) : null}
{canRenderProductionCampaignBadge(amrLoggedIn === true, amrAccountId) ? <ProductionCampaignBadge authenticated sessionSubject={amrAccountId} /> : null}
</>
// No CMS touchpoint here: every placement the app authorizes is a home
// placement (`opend.home.*`), and a project workbench is not home. The
// built-in campaign pill is product chrome, not a CMS host, and stays.
leadingSlot={campaignAudience ? (
<WorkbenchCampaignBadge
audience={campaignAudience}
page="project"
metricsConsent={metricsConsent}
installationId={installationId}
loggedIn={amrLoggedIn}
/>
) : null}
updaterSlot={updaterSlot}
onOpenSettings={onOpenSettings}
+13 -6
View File
@@ -771,6 +771,10 @@ export function EntryShell({
deepSeekV4FlashCampaignAudience === 'unknown'
? null
: deepSeekV4FlashCampaignAudience;
// The CMS touchpoints this rail hosts are home placements (`opend.home.*`).
// The rail itself rides every entry view, so the home view — not the rail —
// is what decides whether they may be on screen.
const homeCampaignHostsVisible = view === 'home';
const workspaceBalanceUsd = workspaceBillingBalanceUsd(
workspaceBillingResponse,
workspaceContext,
@@ -1841,7 +1845,7 @@ export function EntryShell({
}}
onOpenSearch={() => setProjectSearchOpen(true)}
open={railOpen}
topRightSlot={topRightCampaignAudience || amrLoggedIn === true ? (
topRightSlot={topRightCampaignAudience || (homeCampaignHostsVisible && amrLoggedIn === true) ? (
<>
{topRightCampaignAudience ? (
<WorkbenchCampaignBadge
@@ -1852,13 +1856,16 @@ export function EntryShell({
loggedIn={amrLoggedIn}
/>
) : null}
{canRenderProductionCampaignBadge(amrLoggedIn === true, amrAccountId) ? <ProductionCampaignBadge authenticated sessionSubject={amrAccountId} /> : null}
{homeCampaignHostsVisible
&& canRenderProductionCampaignBadge(amrLoggedIn === true, amrAccountId) ? <ProductionCampaignBadge authenticated sessionSubject={amrAccountId} /> : null}
{/* The requirements-specific hover entry is its own authorized
touchpoint, besidenot renamed fromthe account badge. */}
<ProductionCampaignHover
authenticated={amrLoggedIn === true}
sessionSubject={amrAccountId}
/>
{homeCampaignHostsVisible ? (
<ProductionCampaignHover
authenticated={amrLoggedIn === true}
sessionSubject={amrAccountId}
/>
) : null}
</>
) : null}
context={railWorkspaceContext}
@@ -24,6 +24,7 @@ import {
} from "./production-touchpoint-loader";
import {
resolveAuthorizationDeadline,
touchpointWithdrawsDisplay,
useTouchpointLifecycle,
type TouchpointLifecycleLoad,
} from "./touchpoint-lifecycle";
@@ -267,11 +268,17 @@ export function ProductionCampaignModal({
const loaded = await loadProductionTouchpointDecision(PLACEMENT, locale, signal, active?.touchpointDecisionId);
if (signal.aborted) return { kind: "clear" };
if (loaded.kind === "revoked") {
clearOpenPresentation();
return active && loaded.receipt.touchpointDecisionId === active.touchpointDecisionId && loaded.receipt.deploymentId === active.deploymentId && loaded.receipt.activityId === active.activityId && loaded.receipt.contentVersionId === active.content.id ? { kind: "clear" } : { kind: "retain" };
const revokesActive =
active !== null &&
loaded.receipt.touchpointDecisionId === active.touchpointDecisionId &&
loaded.receipt.deploymentId === active.deploymentId &&
loaded.receipt.activityId === active.activityId &&
loaded.receipt.contentVersionId === active.content.id;
if (!active || revokesActive) clearOpenPresentation();
return revokesActive ? { kind: "clear" } : { kind: "retain" };
}
if (loaded.kind === "no-decision") {
clearOpenPresentation();
if (!active) clearOpenPresentation();
return active ? { kind: "retain" } : { kind: "clear" };
}
const next = loaded.value as Decision;
@@ -300,20 +307,35 @@ export function ProductionCampaignModal({
(presentation.sessionSubject !== sessionSubject || presentation.deadline <= Date.now())
)
clearOpenPresentation();
// Only this mounted activity may cross a locale transition. A stored impression
// never overrides a fresh authorization, expiry, revocation, or account fence.
// Only the presentation still on screen may cross a locale transition or a
// lease renewal. `active` is not that test: a lease revoked by the page
// fence stays behind as the revalidation subject, so keying the exemption
// on its activity let every wake re-offer an activity this device had
// already been shown. A stored impression never overrides a fresh
// authorization, expiry, revocation, or account fence.
const continuesOpenPresentation =
openPresentation.current === presentation &&
presentation?.sessionSubject === sessionSubject &&
presentation.activityId === next.activityId &&
presentation.deadline > Date.now();
if (!continuesOpenPresentation && active?.activityId !== next.activityId && wasDisplayed(sessionSubject, next.activityId)) return { kind: "retain" };
// A recorded activity that is not the open presentation may not be
// published. Retaining is only for an offer arriving BESIDE a live
// presentation, which keeps its mount; with nothing on screen a retain
// would republish the very lease the page fence just withdrew, so the
// suppressed offer has to clear instead.
if (!continuesOpenPresentation && wasDisplayed(sessionSubject, next.activityId))
return openPresentation.current ? { kind: "retain" } : { kind: "clear" };
return { kind: "decision", value: { ...next, sessionSubject }, key: next.touchpointDecisionId + ":" + next.deploymentId + ":" + next.activityId + ":" + next.content.id, validForMs: deadline - serverTime };
},
[clearOpenPresentation, locale, sessionSubject],
);
const onError = useCallback((error: unknown) => {
clearOpenPresentation();
// The lifecycle keeps display authority through a transport failure and
// ends it only for the server's own withdrawal; the presentation on screen
// has to follow the same rule. Releasing it on every error told the
// impression gate the modal was gone while it was still mounted, so the
// recovering poll suppressed the activity it was still showing.
if (touchpointWithdrawsDisplay(error)) clearOpenPresentation();
const diagnostic = emitProductionTouchpointLoadDiagnostic(error);
if (diagnostic) emitWebTouchpointDiagnostic(diagnostic);
}, [clearOpenPresentation]);
@@ -327,6 +349,19 @@ export function ProductionCampaignModal({
if (!authenticated || !sessionSubject || openPresentation.current?.sessionSubject !== sessionSubject)
clearOpenPresentation();
}, [authenticated, clearOpenPresentation, sessionSubject]);
/**
* A hidden page (screen sleep, an occluded window) withdraws the lease and
* takes this modal down with it. That presentation is over, so it may not
* continue into the refresh that follows on wake: the device impression
* decides that new offer like any other.
*/
useEffect(() => {
const fence = () => {
if (document.hidden) clearOpenPresentation();
};
document.addEventListener("visibilitychange", fence);
return () => document.removeEventListener("visibilitychange", fence);
}, [clearOpenPresentation]);
useEffect(() => {
ensureWebTouchpointElement();
}, []);
@@ -70,7 +70,7 @@ export const REQUEST_TIMEOUT_MS = 15_000;
export const RETRY_BACKOFF_MS = [1_000, 3_000] as const;
const MAX_TIMER_MS = 2_147_483_647;
/** Only a failure carrying the server's own withdrawal may end a live lease. */
const withdrawsDisplay = (error: unknown) =>
export const touchpointWithdrawsDisplay = (error: unknown) =>
typeof error === "object" && error !== null && (error as { touchpointWithdrawal?: unknown }).touchpointWithdrawal === true;
/**
@@ -134,7 +134,7 @@ export function useTouchpointLifecycle<T>({ enabled, identity, load, onError }:
*/
const abandonAttempt = (error: unknown) => {
cancelRequest();
if (withdrawsDisplay(error) || !lease.current || elapsed(lease.current.start) >= lease.current.validForMs) {
if (touchpointWithdrawsDisplay(error) || !lease.current || elapsed(lease.current.start) >= lease.current.validForMs) {
revalidationLease = null;
status = "error";
revoke();
@@ -152,7 +152,7 @@ export function useTouchpointLifecycle<T>({ enabled, identity, load, onError }:
const delay = RETRY_BACKOFF_MS[retryIndex] ?? 0;
const remainingInCycle = cycleStart === null ? 0 : POLL_MS - elapsed(cycleStart);
if (
!withdrawsDisplay(error) &&
!touchpointWithdrawsDisplay(error) &&
retryIndex < RETRY_BACKOFF_MS.length &&
delay + REQUEST_TIMEOUT_MS <= remainingInCycle
) {
@@ -58,36 +58,50 @@ describe('DeepSeek V4 Flash workbench campaign entry', () => {
expect(entryShellSource).toContain("deepSeekV4FlashCampaignAudience === 'unknown'");
});
it('keeps the top-right campaign entry visible across entry tabs and project detail', () => {
it('keeps the built-in campaign entry visible across entry tabs and project detail', () => {
// The DeepSeek pill is product chrome and rides every surface. The CMS
// touchpoints beside it are home placements and are asserted separately
// below; that is why this slot may no longer open on `amrLoggedIn` alone.
expect(entryShellSource).toMatch(
/topRightSlot=\{\s*topRightCampaignAudience\s*\|\|\s*amrLoggedIn === true\s*\?\s*\(/,
/topRightSlot=\{\s*topRightCampaignAudience\s*\|\|\s*\(homeCampaignHostsVisible && amrLoggedIn === true\)\s*\?\s*\(/,
);
expect(entryShellSource).toMatch(
/topRightSlot=\{[\s\S]*?\{\s*topRightCampaignAudience\s*\?\s*\([\s\S]*?<WorkbenchCampaignBadge[\s\S]*?audience=\{topRightCampaignAudience\}[\s\S]*?page="home"/,
);
expect(entryShellSource).toMatch(
/canRenderProductionCampaignBadge\(amrLoggedIn === true, amrAccountId\)\s*\?\s*<ProductionCampaignBadge authenticated sessionSubject=\{amrAccountId\}/,
);
expect(entryShellSource).toMatch(
/<ProductionCampaignHover\s+authenticated=\{amrLoggedIn === true\}\s+sessionSubject=\{amrAccountId\}/,
);
expect(entryShellSource).not.toMatch(
/topRightSlot=\{\s*view === 'home'/,
);
expect(entryNavRailSource).toMatch(
/export function WorkspaceTopRightAccountCluster[\s\S]*?leadingSlot=\{\s*campaignAudience\s*\|\|\s*canRenderProductionCampaignBadge\(amrLoggedIn === true, amrAccountId\)\s*\?\s*\(/,
expect(entryShellSource).not.toMatch(
/topRightSlot=\{\s*homeCampaignHostsVisible\s*&&/,
);
expect(entryNavRailSource).toMatch(
/export function WorkspaceTopRightAccountCluster[\s\S]*?\{\s*campaignAudience\s*\?\s*\([\s\S]*?<WorkbenchCampaignBadge[\s\S]*?audience=\{campaignAudience\}[\s\S]*?page="project"/,
/export function WorkspaceTopRightAccountCluster[\s\S]*?leadingSlot=\{\s*campaignAudience\s*\?\s*\(/,
);
expect(entryNavRailSource).toMatch(
/export function WorkspaceTopRightAccountCluster[\s\S]*?canRenderProductionCampaignBadge\(amrLoggedIn === true, amrAccountId\)\s*\?\s*<ProductionCampaignBadge authenticated sessionSubject=\{amrAccountId\}/,
/export function WorkspaceTopRightAccountCluster[\s\S]*?leadingSlot=\{[\s\S]*?<WorkbenchCampaignBadge[\s\S]*?audience=\{campaignAudience\}[\s\S]*?page="project"/,
);
expect(appSource).toMatch(
/<WorkspaceTopRightAccountCluster[\s\S]*?amrLoggedIn=\{amrLoginStatus\?\.loggedIn \?\? null\}[\s\S]*?metricsConsent=\{config\.telemetry\?\.metrics === true\}/,
);
});
it('keeps every CMS touchpoint host on the home view', () => {
// `opend.home.*` is the only placement family this app authorizes, so the
// account badge, the hover entry and the modals are home hosts. The
// workbench corner carries none of them.
expect(entryShellSource).toContain("const homeCampaignHostsVisible = view === 'home';");
expect(entryShellSource).toMatch(
/homeCampaignHostsVisible\s*&&\s*canRenderProductionCampaignBadge\(amrLoggedIn === true, amrAccountId\)\s*\?\s*<ProductionCampaignBadge authenticated sessionSubject=\{amrAccountId\}/,
);
expect(entryShellSource).toMatch(
/homeCampaignHostsVisible\s*\?\s*\(\s*<ProductionCampaignHover\s+authenticated=\{amrLoggedIn === true\}\s+sessionSubject=\{amrAccountId\}/,
);
expect(entryNavRailSource).not.toContain('ProductionCampaignBadge');
expect(appSource).toMatch(
/\{route\.kind === 'home' && route\.view === 'home' && \([\s\S]*?<TestCampaignModal[\s\S]*?<ProductionCampaignModal/,
);
});
it('sends both Go and paid DeepSeek badges to public Pricing', () => {
expect(entryShellSource).not.toContain('amrPlansUrlForWorkspace');
expect(workbenchCampaignBadgeSource).toContain('goPlanPricingUrl(locale)');
@@ -11,6 +11,7 @@
// the first agent probe — the exact window in which the bug surfaces.
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { installMockOpenDesignHost } from '@open-design/host/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { App } from '../../src/App';
@@ -44,6 +45,18 @@ vi.mock('../../src/components/TestCampaignModal', async (importOriginal) => ({
authenticated ? <div role="dialog" aria-label="Test campaign witness" /> : null,
}));
// The badge and the hover entry are the top-right CMS touchpoints. Their own
// suites cover authorization and content; here they only report whether App's
// route gating lets either one on screen.
vi.mock('../../src/components/ProductionCampaignBadge', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/components/ProductionCampaignBadge')>()),
ProductionCampaignBadge: () => <div data-testid="production-campaign-badge-witness" />,
}));
vi.mock('../../src/components/ProductionCampaignHover', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/components/ProductionCampaignHover')>()),
ProductionCampaignHover: () => <div data-testid="production-campaign-hover-witness" />,
}));
vi.mock('../../src/analytics/provider', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/analytics/provider')>();
return {
@@ -279,6 +292,70 @@ describe('CMS campaigns during onboarding', () => {
});
});
describe('CMS campaigns outside the home view', () => {
// The top-right hosts are desktop-only, so the route assertions need a host
// bridge in place before they can say anything about gating.
let restoreHost: (() => void) | undefined;
beforeEach(() => {
restoreHost = installMockOpenDesignHost();
});
afterEach(() => {
restoreHost?.();
restoreHost = undefined;
});
// Every campaign placement this app authorizes is a HOME placement
// (`opend.home.*`). The hosts follow: the modal and the top-right entries
// belong to the home view, not to a project workbench or another entry tab.
const witnesses = () => ({
modals: [
screen.queryByRole('dialog', { name: 'Production campaign witness' }),
screen.queryByRole('dialog', { name: 'Test campaign witness' }),
],
topRight: [
screen.queryByTestId('production-campaign-badge-witness'),
screen.queryByTestId('production-campaign-hover-witness'),
],
});
async function arriveOnHome() {
window.history.replaceState(null, '', '/onboarding');
render(<App />);
await screen.findByRole('button', { name: /Continue \(signed in\)/i });
await act(async () => navigate({ kind: 'home', view: 'home' }));
await screen.findByRole('dialog', { name: 'Production campaign witness' });
await screen.findByTestId('production-campaign-badge-witness');
await screen.findByTestId('production-campaign-hover-witness');
}
it('withdraws every host on another entry view and restores them on home', async () => {
await arriveOnHome();
await act(async () => navigate({ kind: 'home', view: 'projects' }));
const away = witnesses();
expect(away.modals).toEqual([null, null]);
expect(away.topRight).toEqual([null, null]);
await act(async () => navigate({ kind: 'home', view: 'home' }));
await screen.findByRole('dialog', { name: 'Production campaign witness' });
await screen.findByTestId('production-campaign-badge-witness');
});
it('withdraws every host on an open project', async () => {
await arriveOnHome();
await act(async () =>
navigate({
kind: 'project',
projectId: 'project-1',
conversationId: null,
fileName: null,
}),
);
const inProject = witnesses();
expect(inProject.modals).toEqual([null, null]);
expect(inProject.topRight).toEqual([null, null]);
});
});
describe('onboarding -> home AMR selection (end to end)', () => {
// Known PR #6475 race: when AMR detection trails the first agent probe, the
// Home switcher can still settle on the registry-first `default` agent after
@@ -1432,6 +1432,116 @@ describe("ProductionCampaignModal device impressions", () => {
await screen.findByRole("dialog");
await waitFor(() => expect(localStorage.getItem(marker())).toBe("1"));
});
it("does not re-present a displayed campaign after the page is hidden and shown again", async () => {
// Screen sleep hides the page, which withdraws the lease and takes the
// modal down. Waking is a NEW presentation, not a renewal: the recorded
// impression has to close it even though the server still offers the
// same activity.
let hidden = false;
vi.spyOn(document, "hidden", "get").mockImplementation(() => hidden);
render(<ProductionCampaignModal authenticated sessionSubject="user-a" />);
await screen.findByRole("dialog");
await waitFor(() => expect(localStorage.getItem(marker())).toBe("1"));
hidden = true;
await act(async () => {
fireEvent(document, new Event("visibilitychange"));
});
expect(screen.queryByRole("dialog")).toBeNull();
hidden = false;
await act(async () => {
fireEvent(document, new Event("visibilitychange"));
});
await act(async () => {});
expect(screen.queryByRole("dialog")).toBeNull();
});
it("keeps the displayed campaign on screen when a poll fails and its retry recovers", async () => {
// A transport failure is not a withdrawal: the lifecycle keeps the lease
// and retries inside the same cycle. The presentation has to survive with
// it, or the recovering poll reads the device impression and closes the
// activity that never left the screen.
vi.useFakeTimers({
toFake: [
"Date",
"performance",
"setTimeout",
"clearTimeout",
"setInterval",
"clearInterval",
],
});
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
let calls = 0;
const fetchMock = vi.fn(async () => {
calls += 1;
if (calls === 2) throw new TypeError("Failed to fetch");
return new Response(JSON.stringify(decision()), { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
render(<ProductionCampaignModal authenticated sessionSubject="user-a" />);
await act(async () => {
await vi.advanceTimersByTimeAsync(10);
});
expect(document.querySelector("opend-touchpoint")).not.toBeNull();
// Fake timers do not drive jsdom's animation frames, so record the
// impression the paint would have recorded.
localStorage.setItem(marker(), "1");
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});
expect(document.querySelector("opend-touchpoint")).not.toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(1_500);
});
expect(calls).toBeGreaterThanOrEqual(3);
expect(document.querySelector("opend-touchpoint")).not.toBeNull();
expect(screen.queryByRole("dialog")).not.toBeNull();
});
it.each(["no-decision", "stale-revocation"] as const)(
"keeps the displayed campaign on screen when a retained %s poll recovers",
async (interim) => {
const active = decision();
let calls = 0;
const fetchMock = vi.fn(async () => {
calls += 1;
if (calls !== 2)
return new Response(JSON.stringify(active), { status: 200 });
if (interim === "no-decision")
return new Response(null, { status: 404 });
return new Response(
JSON.stringify({
error: "production_runtime_revoked",
receipt: {
touchpointDecisionId: active.touchpointDecisionId,
deploymentId: "stale-deployment",
activityId: active.activityId,
contentVersionId: active.content.id,
},
}),
{ status: 410 },
);
});
vi.stubGlobal("fetch", fetchMock);
render(<ProductionCampaignModal authenticated sessionSubject="user-a" />);
await waitFor(() => expect(calls).toBe(1));
const host = document.querySelector("opend-touchpoint");
expect(host).not.toBeNull();
// The host is inserted before its asynchronous mount finishes. Wait for
// the visibility record so this test cannot race a focus refresh against
// creation of the open-presentation guard it is meant to exercise.
await waitFor(() => expect(localStorage.getItem(marker())).toBe("1"));
await act(async () => {
fireEvent(window, new Event("focus"));
});
await waitFor(() => expect(calls).toBe(2));
expect(document.querySelector("opend-touchpoint")).toBe(host);
await act(async () => {
fireEvent(window, new Event("focus"));
});
await waitFor(() => expect(calls).toBe(3));
expect(document.querySelector("opend-touchpoint")).toBe(host);
expect(screen.queryByRole("dialog")).not.toBeNull();
},
);
it("keeps the existing badge and its manual static action usable after automatic suppression", async () => {
localStorage.setItem(marker(), "1");
const placementKey = "opend.home.account-badge";