Files
Caprika 8fb94d28eb feat(cms): 统一客户端活动点位接入与验收修复 (#7986)
* feat(cms): add verified desktop touchpoint hosts and Test acceptance

* fix(cms): keep Test modal scrim above app chrome

* fix(cms): load host styles and support hover packages

* fix(cms): report mounted host compatibility for restoration

* fix(cms): parse Vela actions in shared manifests without executing them

* fix(cms): preserve authorized actions and correct modal and hover behavior (#8003)

* fix(cms): harden touchpoint host interactions

* fix(cms): preserve modal actions and verify effective close controls

* test(cms): cover hover exit and above-anchor placement

* fix(cms): hide client test controls by default

* fix(cms): persist automatic campaign impressions per account and device

* fix(web): remove host campaign close button

* fix(cms): suppress campaign modals during onboarding

* fix(cms): unify test and production campaign lifecycle (#8118)

* fix(cms): enable safe campaign navigation in test and production

* fix(cms): enforce server-authorized touchpoint lifetimes

* fix(cms): refresh production touchpoints when client language changes

* fix(cms): stabilize hover refresh and synchronize Test locale

* fix(cms): preserve campaigns on focus and remember Test dismissal

* fix(cms): discover Test deployments without reloading

* fix(cms): keep granted touchpoint display authority through transport failures

A refresh that times out or fails in transport is not a withdrawal, but the
shared lifecycle treated every failure the same way: `status = "error"` then
`revoke()`, which tore down a lease the server had already granted and was
still inside its own deadline. One slow poll therefore blanked a running
campaign until the next 30s tick, and a second failure could not restore it
because the value had been dropped.

`abandonAttempt` now names the invariant: cancel the attempt, keep the lease,
and let `armExpiry` retire it at the deadline the server actually granted. The
lease length is the bound, so no retry budget is needed — Test's sixty-second
contract survives exactly one missed poll, and Production survives as many as
its authorization covers.

Three cases still clear, and one of them is new:

  - the failure carries the server's own withdrawal. A 410 is authoritative
    even when its receipt body is unreadable, so `ProductionTouchpointLoadError`
    now marks that one case and the lifecycle honours it. Without this, a
    malformed 410 would have been ridden out as if it were transport noise.
  - nothing is mounted, or the lease was already fenced by `wake`. Page
    recovery has no evidence the activity is still live, so it stays withdrawn.
  - the lease has run out on its own.

This does not fix OPEND-3237, which is a new activity failing its FIRST load:
there is no prior lease to keep and the display stays empty. It fixes the
adjacent class where a mounted activity is erased by a transient refresh.

* fix(cms): keep a failed refresh recoverable inside its own poll cycle

Three changes to how one refresh cycle behaves when it does not go cleanly.
None of them fixes OPEND-3237, whose first-load failure is still unexplained;
they remove the ways a working campaign is lost to an ordinary hiccup.

Bounded retries. A failed attempt used to get its next chance from the fixed
30s tick. For the sixty-second lease the Test runtime is granted, that chance
lands exactly when the lease expires, so a single failure put display on the
edge of going blank with nothing able to recover it. Retries at 1s and 3s give
a fast failure — transport error, 5xx, DNS — several chances while the lease is
still comfortably alive.

A retry must finish inside the cycle that spawned it. `refresh` declines to
start while a request is in flight, so a chain that ran past the next tick
would not merely be late: it would swallow that tick entirely. Worst case was
15+1+15+3+15 = 49s against a 30s interval. Scheduling now requires room for the
retry AND its full budget, which is also what makes "retries cover fast
failures" true in the code rather than only in a comment — an attempt that
burned the whole budget leaves no room by construction and is never retried.
The retry budget resets per cycle, not per success: otherwise the first cycle
to exhaust its retries would leave every later cycle with none.

Request budget 10s -> 15s. One refresh fetches a context and every enabled
placement, so the budget covers a round, not a request. A real round of 11.5s
whose placements all succeeded was being abandoned by the ten-second budget. It
stays well under the 30s interval on purpose.

Forward `accept-encoding` through the touchpoint runtime proxy. Decisions carry
base64 content and run to megabytes, and the proxy built its upstream headers
from scratch, dropping the caller's preference — every refresh pulled the
payload uncompressed, measured at 383KB against 214KB for the same decision.
Because the body is piped through verbatim, upstream's `content-encoding` has
to be handed back with it, or the caller decodes gzip as JSON; both halves land
together. Verified end to end through daemon and web: 2,149,685 -> 1,606,678
bytes on the wire, byte-identical after decode.

An authoritative withdrawal is never retried: a 410 that took a campaign off
the screen must not put it back.

* fix(cms): present each Test campaign modal once per account and device

Test modal presentations now share the production touchpoint-displayed:v1
impression: recorded once visible, read before any new automatic opening,
and persisted across reload, remount, and client restart.

* fix(cms): give the Test control readout an explicit ladder weight
2026-09-17 05:58:48 +00:00

489 lines
15 KiB
TypeScript

import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import type { Page, Route } from "@playwright/test";
import {
mockAmrPersonalWorkspace,
mockAmrWalletSnapshot,
} from "@/playwright/amr";
import { applyStandardMocks } from "@/playwright/mock-factory";
import { expect, test } from "@/playwright/suite";
import { T } from "@/timeouts";
const { cmsHostReleaseFingerprint } = (await import(
`${process.cwd()}/../apps/web/next.config.ts`
)) as {
cmsHostReleaseFingerprint: (
readFile: (file: string) => string | Buffer,
) => string;
};
const WORKSPACE_ROOT = resolve(process.cwd(), "..");
const digest = (value: string | Buffer) =>
`sha256:${createHash("sha256").update(value).digest("hex")}`;
const hostReleaseFingerprint = cmsHostReleaseFingerprint((file) =>
readFileSync(resolve(WORKSPACE_ROOT, file)),
);
const ENTRY_PLACEMENT = "opend.home.hover-entry";
const LAYER_PLACEMENT = "opend.home.hover-layer";
const BADGE_PLACEMENT = "opend.home.account-badge";
const ACTION_ID = "learn";
const entryModule = `
export function mount(root) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Open campaign';
button.style.cssText = 'width: 128px; height: 32px;';
root.append(button);
return button;
}
`;
const layerModule = `
export function mount(root, context, sdk) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Learn more';
button.style.cssText = 'width: 180px; height: 72px;';
button.addEventListener('click', () => void sdk.dispatchAction('${ACTION_ID}'));
root.append(button);
return button;
}
`;
const badgeModule = `
export function mount(root) {
const badge = document.createElement('span');
badge.textContent = 'Campaign badge';
badge.style.cssText = 'display: inline-block; width: 116px; height: 24px;';
root.append(badge);
return badge;
}
`;
const manifest = {
formatVersion: 2 as const,
runtimeKind: "web-component" as const,
runtimeApiVersion: 1 as const,
platformWrapperVersion: "vela-touchpoint-wrapper-v1" as const,
sdkVersion: "vela-touchpoint-sdk-v1" as const,
contentLine: "cms-hover-browser-fixture",
placements: [
{
key: ENTRY_PLACEMENT,
entry: "hover-entry.js",
resources: [],
locales: ["en-US"],
requiredCapabilities: ["hover", "static-action"],
staticActions: [
{
id: ACTION_ID,
target: {
kind: "https" as const,
url: "https://example.com/campaign",
},
},
],
},
{
key: LAYER_PLACEMENT,
entry: "hover-layer.js",
resources: [],
locales: ["en-US"],
requiredCapabilities: ["hover", "static-action"],
staticActions: [
{
id: ACTION_ID,
target: {
kind: "https" as const,
url: "https://example.com/campaign",
},
},
],
},
{
key: BADGE_PLACEMENT,
entry: "badge.js",
resources: [],
locales: ["en-US"],
requiredCapabilities: ["static-action"],
staticActions: [
{
id: ACTION_ID,
target: {
kind: "https" as const,
url: "https://example.com/campaign",
},
},
],
},
],
resources: ["hover-entry.js", "hover-layer.js", "badge.js"],
images: [],
};
const moduleByPlacement = new Map([
[ENTRY_PLACEMENT, ["hover-entry.js", entryModule]],
[LAYER_PLACEMENT, ["hover-layer.js", layerModule]],
[BADGE_PLACEMENT, ["badge.js", badgeModule]],
] as const);
function contentFor(placementKey: string) {
const [entryPath, entry] =
moduleByPlacement.get(placementKey as typeof ENTRY_PLACEMENT) ?? [];
if (!entryPath || !entry)
throw new Error(`missing fixture module for ${placementKey}`);
return {
id: "cms-hover-browser-version",
placementKey,
locale: "en-US",
manifest,
manifestHash: digest(JSON.stringify(manifest)),
entryPath,
entryDigest: digest(entry),
entryModule: entry,
resources: [
{
path: entryPath,
digest: digest(entry),
bytes: Buffer.from(entry).toString("base64"),
},
],
runtime: {
kind: "web-component" as const,
apiVersion: 1 as const,
wrapperVersion: "vela-touchpoint-wrapper-v1" as const,
sdkVersion: "vela-touchpoint-sdk-v1" as const,
},
buildIdentity: { fingerprint: hostReleaseFingerprint },
};
}
function decisionFor(placementKey: string) {
const now = Date.now();
return {
activityId: "cms-hover-browser-activity",
touchpointDecisionId: `cms-hover-${placementKey}`,
deploymentId: "cms-hover-browser-deployment",
authorizationExpiresAt: new Date(now + 60_000).toISOString(),
endsAt: new Date(now + 300_000).toISOString(),
serverTime: new Date(now).toISOString(),
placementKey,
requiredCapabilities:
placementKey === BADGE_PLACEMENT
? ["static-action"]
: ["hover", "static-action"],
staticActions: [
{
id: ACTION_ID,
target: { kind: "https" as const, url: "https://example.com/campaign" },
},
],
content: contentFor(placementKey),
};
}
async function installDesktopHost(page: Page) {
await page.addInitScript(() => {
(window as Window & { __od__?: unknown }).__od__ = {
version: 2,
client: { type: "desktop", platform: "test", osLocale: "en-US" },
shell: {
openExternal: async () => ({ ok: true }),
openPath: async () => ({ ok: true }),
},
browser: { clearData: async () => ({ ok: true }) },
capture: {
page: async () => ({
ok: true,
dataUrl: "data:image/png;base64,",
h: 1,
w: 1,
}),
},
project: {
pickAndImport: async () => ({
ok: true,
projectId: "fixture",
conversationId: "fixture",
entryFile: "index.html",
}),
pickAndReplaceWorkingDir: async () => ({
ok: true,
baseDir: "/tmp/fixture",
entryFile: null,
}),
},
pdf: { print: async () => ({ ok: true }) },
pet: { setVisible: () => undefined },
updater: {
status: async () => ({
arch: "arm64",
capabilities: {},
channel: "beta",
currentVersion: "fixture",
enabled: true,
mode: "test",
platform: "test",
state: "idle",
supported: true,
}),
check: async () => ({ ok: true }),
"clear-cache": async () => ({ ok: true }),
download: async () => ({ ok: true }),
install: async () => ({ ok: true }),
quit: async () => ({ ok: true }),
setMenuLabels: async () => ({ ok: true }),
subscribe: () => () => undefined,
subscribeOpenDialog: () => () => undefined,
},
};
});
}
async function installCmsFixture(page: Page) {
const responses: Array<{ placementKey: string; fingerprint: string }> = [];
const events: Array<Record<string, unknown>> = [];
await page.route("**/api/integrations/vela/status", async (route) => {
await route.fulfill({
json: {
loggedIn: true,
profile: "local",
user: {
id: "cms-hover-user",
email: "cms-hover@example.com",
plan: "free",
},
},
});
});
await mockAmrPersonalWorkspace(page, undefined, { accountPlan: "free" });
await mockAmrWalletSnapshot(page, {
email: "cms-hover@example.com",
plan: "free",
});
await page.route(
"**/api/touchpoints/production-runtime**",
async (route: Route) => {
const request = route.request();
if (request.method() === "POST") {
events.push(request.postDataJSON() as Record<string, unknown>);
await route.fulfill({ json: { ok: true } });
return;
}
const placementKey = new URL(request.url()).searchParams.get(
"placementKey",
);
if (!placementKey || placementKey === "opend.home.campaign-modal") {
await route.fulfill({ status: 404, json: { error: "no_decision" } });
return;
}
const decision = decisionFor(placementKey);
responses.push({
placementKey,
fingerprint: decision.content.buildIdentity.fingerprint,
});
await route.fulfill({ json: decision });
},
);
return { responses, events };
}
async function gotoCmsHome(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByText("Loading OpenDesign…")).toHaveCount(0, {
timeout: T.long,
});
await expect(page.getByTestId("home-hero")).toBeVisible({ timeout: T.long });
}
test.describe.configure({ timeout: T.xlong });
test.beforeEach(async ({ page }) => {
await applyStandardMocks(page);
await installDesktopHost(page);
});
test("[P1] controlled CMS fixture mounts the top-right badge and hover pair in the real browser", async ({
page,
}) => {
const fixture = await installCmsFixture(page);
await gotoCmsHome(page);
const cluster = page.locator(".entry-top-right-cluster");
await expect(cluster).toBeVisible();
const badge = cluster.getByTestId("production-campaign-badge");
await expect(badge).toBeVisible({ timeout: T.medium });
const hoverRoot = cluster.getByTestId("cms-hover-overlay-root");
const entry = hoverRoot.locator("opend-touchpoint").first();
await expect(entry).toBeVisible();
await expect.poll(() => fixture.responses.length).toBeGreaterThanOrEqual(3);
expect(
fixture.responses.every(
(response) => response.fingerprint === hostReleaseFingerprint,
),
).toBe(true);
expect(
await page
.locator(
'.entry-top-right-cluster [data-testid="cms-hover-overlay-root"]',
)
.count(),
).toBe(1);
expect(
await page
.locator('[style*="bottom"] [data-testid="cms-hover-overlay-root"]')
.count(),
).toBe(0);
});
test("[P1] controlled CMS fixture keeps the real hover layer open across three slow 8px crossings each way", async ({
page,
}) => {
const fixture = await installCmsFixture(page);
await gotoCmsHome(page);
const hoverRoot = page.getByTestId("cms-hover-overlay-root");
const entry = hoverRoot.locator("opend-touchpoint").first();
const layer = hoverRoot.locator("opend-touchpoint").nth(1);
await expect(entry).toBeVisible({ timeout: T.medium });
await entry.hover();
await expect(layer).toBeVisible({ timeout: T.medium });
const entryBox = await entry.boundingBox();
const layerBox = await layer.boundingBox();
expect(entryBox).not.toBeNull();
expect(layerBox).not.toBeNull();
const entryPoint = {
x: entryBox!.x + entryBox!.width / 2,
y: entryBox!.y + entryBox!.height / 2,
};
const layerPoint = {
x: layerBox!.x + layerBox!.width / 2,
y: layerBox!.y + layerBox!.height / 2,
};
const layerIsBelow = layerBox!.y >= entryBox!.y + entryBox!.height;
const gap = layerIsBelow
? layerBox!.y - (entryBox!.y + entryBox!.height)
: entryBox!.y - (layerBox!.y + layerBox!.height);
expect(gap).toBe(8);
const bridgePoint = {
x: 0,
y: layerIsBelow
? entryBox!.y + entryBox!.height + gap / 2
: layerBox!.y + layerBox!.height + gap / 2,
};
bridgePoint.x =
Math.max(entryBox!.x, layerBox!.x) +
(Math.min(entryBox!.x + entryBox!.width, layerBox!.x + layerBox!.width) -
Math.max(entryBox!.x, layerBox!.x)) /
2;
const cross = async (from: typeof entryPoint, to: typeof layerPoint) => {
await page.mouse.move(from.x, from.y);
await page.mouse.move(bridgePoint.x, bridgePoint.y, { steps: 8 });
await page.mouse.move(to.x, to.y, { steps: 6 });
};
for (let index = 0; index < 3; index += 1) {
await cross(entryPoint, layerPoint);
await expect(layer).toBeVisible();
await cross(layerPoint, entryPoint);
await expect(layer).toBeVisible();
}
const action = layer.locator("button", { hasText: "Learn more" });
await expect(action).toBeVisible();
await action.click();
await expect.poll(() => fixture.events.length).toBeGreaterThanOrEqual(1);
expect(fixture.events.at(-1)).toMatchObject({
activityId: "cms-hover-browser-activity",
placementKey: LAYER_PLACEMENT,
touchpointDecisionId: `cms-hover-${LAYER_PLACEMENT}`,
kind: "click",
});
});
test("[P1] controlled CMS fixture stays within a narrow viewport and restores focus on Escape", async ({
page,
}) => {
const fixture = await installCmsFixture(page);
await gotoCmsHome(page);
await page.setViewportSize({ width: 360, height: 160 });
const hoverRoot = page.getByTestId("cms-hover-overlay-root");
const entry = hoverRoot.locator("opend-touchpoint").first();
const layer = hoverRoot.locator("opend-touchpoint").nth(1);
await expect(entry).toBeVisible({ timeout: T.medium });
await entry.focus();
await expect(layer).toBeVisible({ timeout: T.medium });
const box = await layer.boundingBox();
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.y).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(360);
expect(box!.y + box!.height).toBeLessThanOrEqual(160);
await page.keyboard.press("Escape");
await expect(layer).toBeHidden();
await expect(entry).toBeFocused();
expect(
fixture.responses.some(
(response) => response.placementKey === ENTRY_PLACEMENT,
),
).toBe(true);
});
test("[P1] controlled CMS fixture closes the hover layer after the pointer leaves the entry-layer union", async ({
page,
}) => {
await installCmsFixture(page);
await gotoCmsHome(page);
const hoverRoot = page.getByTestId("cms-hover-overlay-root");
const entry = hoverRoot.locator("opend-touchpoint").first();
const layer = hoverRoot.locator("opend-touchpoint").nth(1);
await expect(entry).toBeVisible({ timeout: T.medium });
await entry.hover();
await expect(layer).toBeVisible({ timeout: T.medium });
const entryBox = await entry.boundingBox();
const layerBox = await layer.boundingBox();
if (!entryBox || !layerBox)
throw new Error("hover fixture did not expose measurable rectangles");
const outside = { x: 8, y: 8 };
const contains = (box: typeof entryBox, point: typeof outside) =>
point.x >= box.x &&
point.x <= box.x + box.width &&
point.y >= box.y &&
point.y <= box.y + box.height;
expect(contains(entryBox, outside)).toBe(false);
expect(contains(layerBox, outside)).toBe(false);
await page.mouse.move(outside.x, outside.y);
await expect(layer).toBeHidden();
await expect(entry).toHaveAttribute("aria-expanded", "false");
});
test("[P1] controlled CMS fixture flips the hover layer above an anchor near the viewport bottom", async ({
page,
}) => {
await installCmsFixture(page);
await gotoCmsHome(page);
await page.setViewportSize({ width: 640, height: 240 });
const hoverRoot = page.getByTestId("cms-hover-overlay-root");
const entry = hoverRoot.locator("opend-touchpoint").first();
const layer = hoverRoot.locator("opend-touchpoint").nth(1);
await expect(entry).toBeVisible({ timeout: T.medium });
const initialEntryBox = await entry.boundingBox();
if (!initialEntryBox)
throw new Error("hover entry did not expose a measurable rectangle");
const targetTop = 240 - initialEntryBox.height - 16;
await entry.evaluate((element, translateY) => {
element.style.transform = `translateY(${translateY}px)`;
}, targetTop - initialEntryBox.y);
await entry.hover();
await expect(layer).toBeVisible({ timeout: T.medium });
const entryBox = await entry.boundingBox();
const layerBox = await layer.boundingBox();
if (!entryBox || !layerBox)
throw new Error("hover fixture did not expose measurable rectangles");
expect(layerBox.y + layerBox.height).toBe(entryBox.y - 8);
expect(layerBox.y).toBeGreaterThanOrEqual(8);
expect(layerBox.y + layerBox.height).toBeLessThanOrEqual(240 - 8);
});