mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
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:
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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, beside—not renamed from—the 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";
|
||||
|
||||
Reference in New Issue
Block a user