Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions packages/web/e2e/push-banner.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { PUSH_GRACE_MS as GRACE_MS } from "../src/state/reconnect.js";
import type { Locator } from "@playwright/test";
import { expect, login, openBot, test, type Page } from "./fixtures";

declare global {
interface Window {
__pushBannerSeen?: boolean;
}
}

const refusePush = async (page: Page, refused: boolean) => {
expect((await page.request.post("/api/test/sockets/refuse", { data: { refused } })).status()).toBe(204);
};

const dropPush = async (page: Page) => {
expect((await page.request.post("/api/test/sockets/drop")).status()).toBe(204);
};

const boxOf = async (locator: Locator) => {
const box = await locator.boundingBox();
if (box === null) throw new Error("元素没有渲染出来");
return box;
};

const overlaps = async (a: Locator, b: Locator) => {
const [x, y] = [await boxOf(a), await boxOf(b)];
return x.x < y.x + y.width && y.x < x.x + x.width && x.y < y.y + y.height && y.y < x.y + x.height;
};

const postMissed = async (page: Page, text: string) => {
const posted = await page.request.post("/api/test/messages", { data: { groupId: "grp_sales", text } });
expect(posted.status(), "断线时服务端多了一条推不过来的消息").toBe(204);
};

test("推送断线超过宽限期,顶上说连不上、正在重试;连回来提示消失,断线期间的消息补拉出来", async ({ page }) => {
const MISSED = "推送断着的时候别的窗口说的话";
await login(page);
await openBot(page, "小销");
const list = page.getByTestId("message-list");
await expect(list.getByTestId("message"), "预置的历史到齐了").not.toHaveCount(0);
const banner = page.getByTestId("push-banner");
await expect(banner, "连着的时候不提示").toHaveCount(0);

await refusePush(page, true);
await dropPush(page);
await expect(banner, "断线超过宽限期就提示").toBeVisible();
await expect(banner).toContainText("连不上");
await expect(banner).toContainText("重试");
await expect(
page.locator("[aria-live=polite]").filter({ has: banner }),
"提示放在礼貌播报的区域里,读屏会念出来",
).toHaveCount(1);
for (const [testId, name] of [
["bot-edit", "编辑"],
["bot-share", "分享"],
] as const) {
const button = page.getByTestId(testId);
await expect(button, `断线时聊天头的「${name}」还看得见`).toBeVisible();
await expect(button).toBeEnabled();
await button.click({ trial: true });
expect(await overlaps(banner, button), `断线提示不压在「${name}」上`).toBe(false);
}

await postMissed(page, MISSED);
await expect(list.locator("[data-kind=user]", { hasText: MISSED }), "断着的时候推不过来").toHaveCount(0);
await expect(banner, "一直连不上就一直提示").toBeVisible();

await refusePush(page, false);
await expect(banner, "连回来提示就收起").toHaveCount(0, { timeout: 15_000 });
await expect(list.locator("[data-kind=user]", { hasText: MISSED }), "连回来补拉出断线期间的消息").toHaveCount(1);
});

test("推送断一下马上连回来,提示不闪出来", async ({ page }) => {
const MISSED = "一闪而过的断线里说的话";
await page.addInitScript(() => {
window.__pushBannerSeen = false;
new MutationObserver(() => {
if (document.querySelector("[data-testid=push-banner]") !== null) window.__pushBannerSeen = true;
}).observe(document, { childList: true, subtree: true });
});
await login(page);
await openBot(page, "小销");
const list = page.getByTestId("message-list");
await expect(list.getByTestId("message"), "预置的历史到齐了").not.toHaveCount(0);

const droppedAt = Date.now();
await dropPush(page);
await postMissed(page, MISSED);
await expect(list.locator("[data-kind=user]", { hasText: MISSED }), "推送已经连回来了").toHaveCount(1);
expect(Date.now() - droppedAt, "这一趟重连要快过宽限期,否则测不出闪不闪").toBeLessThan(GRACE_MS);
await page.waitForTimeout(droppedAt + GRACE_MS + 500 - Date.now());
expect(await page.evaluate(() => window.__pushBannerSeen), "宽限期内连回来,提示一次都没出现过").toBe(false);
});

test("断线提示和顶上的浮动提示同时出现时各占各的位置,不叠在一起", async ({ page }) => {
await login(page);
await page.goto("/?oauth=x&result=failed");
const notice = page.getByTestId("oauth-notice");
await expect(notice).toBeVisible();

await refusePush(page, true);
await dropPush(page);
const banner = page.getByTestId("push-banner");
await expect(banner).toBeVisible();
await expect(notice, "断线不会把授权结果的提示挤掉").toBeVisible();
expect(await overlaps(banner, notice), "授权结果的提示让到断线提示下面").toBe(false);
await notice.getByTestId("oauth-notice-close").click({ trial: true });
});
15 changes: 15 additions & 0 deletions packages/web/mock/routes/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,14 @@ const TestDeltaRequest = z
const LegacyDescriptionRequest = z.object({ botId: BotId, description: z.string() }).strict();
const ManyPreferencesRequest = z.object({ botId: BotId, count: z.number().int().min(1).max(100) }).strict();
const ComputerStatusRequest = z.object({ status: ComputerStatus }).strict();
const PushRefusalRequest = z.object({ refused: z.boolean() }).strict();
const activeTurns = new Map<GroupId, Extract<ServerPush, { type: "group_turn" }>>();
const turnEpochs = new Map<GroupId, number>();

let pushRefused = false;

export const currentTurns = (): Iterable<Extract<ServerPush, { type: "group_turn" }>> => activeTurns.values();
export const refusingPush = (): boolean => pushRefused;

export const testRoutes: Route = async ({ req, res, path, method, push }) => {
if (method === "GET" && path === "/api/health") {
Expand All @@ -109,11 +113,22 @@ export const testRoutes: Route = async ({ req, res, path, method, push }) => {
resetRegisteredEmails();
resetLive();
activeTurns.clear();
pushRefused = false;
turnEpochs.clear();
send(res, 204);
return true;
}

if (method === "POST" && path === "/api/test/sockets/refuse") {
const parsed = PushRefusalRequest.safeParse(await readBody(req));
if (!parsed.success) send(res, 400, { error: "要写明拒不拒推送连接" });
else {
pushRefused = parsed.data.refused;
send(res, 204);
}
return true;
}

// 写入上限收紧之前存下的长描述,PATCH 造不出来,只能从这里直接种。
if (method === "POST" && path === "/api/test/bot-description") {
const parsed = LegacyDescriptionRequest.safeParse(await readBody(req));
Expand Down
6 changes: 5 additions & 1 deletion packages/web/mock/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { hookRoutes, routineRoutes } from "./routes/routines.js";
import { skillRoutes } from "./routes/skills.js";
import { teamOfferRoutes } from "./routes/team-offers.js";
import { templateRoutes } from "./routes/templates.js";
import { currentTurns, testRoutes } from "./routes/test.js";
import { currentTurns, refusingPush, testRoutes } from "./routes/test.js";
import { todayRoutes } from "./routes/today.js";
import { toolRoutes } from "./routes/tools.js";

Expand Down Expand Up @@ -99,6 +99,10 @@ server.on("upgrade", (req, socket, head) => {
socket.destroy();
return;
}
if (live === null && refusingPush()) {
socket.destroy();
return;
}
if (live !== null) {
wss.handleUpgrade(req, socket, head, (ws) => attachLiveViewer(ws, live[1] ?? "", push));
return;
Expand Down
28 changes: 28 additions & 0 deletions packages/web/src/components/PushBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useEffect, useState } from "react";
import { WifiOff } from "lucide-react";
import { PUSH_GRACE_MS } from "../state/reconnect.js";
import type { PushLink } from "../state/types.js";

// 播报区域一直挂着,读屏要先认识这块区域,后来塞进去的字才会念。
export const PushBanner = ({ link }: { link: PushLink }) => {
const [lagging, setLagging] = useState(false);
useEffect(() => {
if (link === "open") return;
const timer = setTimeout(() => setLagging(true), PUSH_GRACE_MS);
return () => {
clearTimeout(timer);
setLagging(false);
};
}, [link]);

return (
<div className="push-status" aria-live="polite">
{lagging ? (
<div className="push-banner" data-testid="push-banner">
<WifiOff size={14} aria-hidden="true" />
<span>连不上服务,正在重试。连上后会补上这段时间的消息</span>
</div>
) : null}
</div>
);
};
2 changes: 2 additions & 0 deletions packages/web/src/routes/MainWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { SettingsWindow } from "../components/SettingsWindow.js";
import { TaskPanel } from "../components/TaskPanel.js";
import { ToolInspector } from "../components/ToolInspector.js";
import { BotCreatedNotice } from "../components/BotCreatedNotice.js";
import { PushBanner } from "../components/PushBanner.js";
import { LiveHost } from "../live/LiveHost.js";
import { TodayPage } from "./TodayPage.js";
import { PokerProjectPage } from "./PokerProjectPage.js";
Expand Down Expand Up @@ -233,6 +234,7 @@ export const MainWindow = () => {

return (
<div className={`window${hasPanel ? "" : " is-wide"}`} data-testid="main-window">
<PushBanner link={ready.pushLink} />
<Sidebar
screen={screen}
conversations={ready.conversations}
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/state/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
socket = new WebSocket(`${scheme}://${window.location.host}/api/ws`);
socket.addEventListener("open", () => {
failures = 0;
put({ type: "push_link", link: "open" });
if (hasConnected) {
put({ type: "push_reconnected", seq: nextSeq() });
refreshPendingCounts();
Expand All @@ -1031,6 +1032,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
// 分不出是登录过期还是断网,拿这次会话打一下 /api/me 问清楚:401 回登录页,别的按退避再连。
socket.addEventListener("close", () => {
if (!live()) return;
put({ type: "push_link", link: "down" });
scoped.api.me().then(
() => {
if (live()) retryLater();
Expand Down
3 changes: 3 additions & 0 deletions packages/web/src/state/reconnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ const MAX_MS = 30_000;

// 推送连着失败 failures 次之后等多久再连:1 秒起,每失败一次翻倍,封顶 30 秒。
export const reconnectDelay = (failures: number): number => Math.min(FIRST_MS * 2 ** failures, MAX_MS);

// 断线后至少要等过第一次重连,还没连回来才值得提示。
export const PUSH_GRACE_MS = 2 * reconnectDelay(0);
2 changes: 2 additions & 0 deletions packages/web/src/state/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const enteredState = (action: Extract<Action, { type: "ready" }>): ReadyState =>
usage,
content: EMPTY_CONTENT,
todayVersion: 0,
pushLink: "down",
reconnectedAt: 0 as Seq,
messagesLoadedAt: {},
messageGaps: {},
Expand Down Expand Up @@ -91,6 +92,7 @@ export const reduce = (state: AppState, action: Action): AppState => {
reconnectedAt: action.seq,
messageGaps: rearmGaps(state.messageGaps),
};
if (action.type === "push_link") return { ...state, pushLink: action.link };
if (action.type === "load_status") return { ...state, loads: { ...state.loads, [action.key]: action.status } };
const groups = reduceGroups(state, action);
const approvals = reduceApprovals(groups, action);
Expand Down
4 changes: 4 additions & 0 deletions packages/web/src/state/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ export type GroupTurn = Extract<ServerPush, { type: "group_turn" }>;

export type TeamOfferOutcome = "confirmed" | "dismissed";

export type PushLink = "down" | "open";

export type AppState =
| { status: "loading" }
| { status: "anonymous"; login: LoginPhase; register: LoginPhase }
Expand Down Expand Up @@ -188,6 +190,7 @@ export type AppState =
usage: UsageResponse;
content: ContentBook;
todayVersion: number;
pushLink: PushLink;
// 推送最近一次重连时取的号,messagesLoadedAt 是每个群的历史请求发出时取的号。比重连早的历史可能漏了断线时的推送。
reconnectedAt: Seq;
messagesLoadedAt: Record<GroupId, Seq>;
Expand Down Expand Up @@ -293,6 +296,7 @@ export type Action =
| { type: "publications_loaded"; publications: Publication[] }
| { type: "today_version_bumped" }
| { type: "push_reconnected"; seq: Seq }
| { type: "push_link"; link: PushLink }
| { type: "load_status"; key: LoadKey; status: LoadStatus }
| { type: "from_session"; session: SessionId; action: Action };

Expand Down
35 changes: 35 additions & 0 deletions packages/web/src/styles/03-status.css
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,38 @@
animation: none;
}
}

/* 断线提示可能一直挂着,占住内容区顶上一行,不浮在聊天头的按钮上。定高不换行,浮动提示才知道该让多少。 */
.push-status {
grid-column: 2 / -1;
grid-row: 1;
}

.push-banner {
display: flex;
gap: var(--space-4);
align-items: center;
justify-content: center;
height: var(--control-h-md);
padding: 0 var(--space-6);
overflow: hidden;
border-bottom: 1px solid var(--border-1);
background: var(--warning-soft);
color: var(--fg-1);
font: var(--type-ui);
white-space: nowrap;
}

.push-banner span {
overflow: hidden;
text-overflow: ellipsis;
}

.push-banner svg {
flex: none;
color: var(--warning);
}

.window:has(.push-banner) > .oauth-notice {
top: calc(var(--control-h-md) + var(--space-6));
}
2 changes: 2 additions & 0 deletions packages/web/src/styles/04-sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
.window {
display: grid;
grid-template-columns: var(--sidebar-w) minmax(0, 1fr) var(--panel-w);
grid-template-rows: auto 1fr;
height: 100%;
background: var(--bg-app);
}

.sidebar {
display: flex;
grid-row: 1 / -1;
flex-direction: column;
gap: var(--space-1);
min-height: 0;
Expand Down
Loading