Macro 团队日历浏览器验证套件:用合成身份端到端校验团队共享、OOO 叠加与点事件渲染
- 后端
- 前端
- 协同办公
- 即时通讯
- 企业应用
- CRM
- AI Agent
- 人工智能
【免费下载链接】macro
Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.
apps/web/tests/calendar-team是 Macro 前端针对团队日历能力构建的一套浏览器级验证工具。它加载完整的打包应用,用合成身份拦截认证、团队与日历 HTTP 流量,在真实浏览器中验证"团队休假叠加层""团队共享策略""计入忙碌的日历"以及"导入点事件的紧凑渲染与仅元数据编辑"等行为。读完后你将掌握该套件的隔离机制、可配置环境变量、运行方式,以及每一项断言背后的前端源码依据,并能在自己的开发环境中复现和扩展这些检查。
一、这套工具要解决什么问题
团队日历是一类典型的"跨账号可见性"功能:一旦写错,用户可能看到同事的日程标题,或者在修改一个设置时整页闪烁加载态。传统的单元测试无法覆盖这些组合行为,而手工点测又难以回归。该验证套件因此被设计成一个合成环境的端到端脚本:真实组件、真实路由、真实渲染管线,唯独 API 传输层被完全接管。
原文档对该套件的边界给出了非常明确的界定:
- 它加载完整打包的应用,用合成身份拦截 auth、team、calendar 的 HTTP 流量;
- 它阻断非本地请求与 WebSocket 流量;
- 它不验证 Rust 服务端,也不验证 Google 权限;
- 自己日历的读取走既有的 REST 回退路径(REST fallback);
- 使用实时时钟,而不是固定时间;
- 在截图前检查加载占位符已经消失。
这些约束决定了它的定位:这是一个前端行为契约测试,而不是集成测试或权限测试。任何涉及服务端授权、Google OAuth scope 或真实同步链路的结论,都不能由这套脚本给出。
二、运行前的准备与执行步骤
套件需要 Chromium,并且所有命令都应在apps/web目录下执行(原文档已明确这一点)。完整流程分三步:
# 1. 用测试专用 vite 配置构建完整应用 MODE=development bunx vite build -c tests/calendar-team/vite.config.ts # 2. 启动预览服务器(固定 127.0.0.1:3037,base 为 /app) bunx vite preview -c tests/calendar-team/vite.config.ts \ --outDir /tmp/calendar-team-browser-dist --base /app --port 3037# 3. 另开一个终端:把浏览器脚本打成 Node 可执行模块并运行 bun build tests/calendar-team/browser.ts --target=node \ --packages=external --outfile=node_modules/.calendar-team-browser/browser.mjs node node_modules/.calendar-team-browser/browser.mjs这里有两个容易被忽略的细节:
- 构建输出目录必须与预览目录一致。配置文件里
build.outDir已固定为/tmp/calendar-team-browser-dist,preview也固定为127.0.0.1:3037且strictPort: true(端口被占用时直接失败而不是漂移)。命令行再传一次--outDir/--base/--port是显式冗余,避免误用其他配置。 - 浏览器脚本要打成一个独立的 Node 入口。
browser.ts顶部使用了顶层await,通过bun build --target=node --packages=external生成 ESM 文件后由node执行,@playwright/test等依赖保持在外部,不被打进 bundle。
环境变量一览
| 变量 | 作用 | 默认/说明 |
|---|---|---|
CHROMIUM_PATH | 指定替代的 Chromium 二进制 | 默认/usr/bin/chromium,见 browser.ts |
CALENDAR_TEAM_BROWSER_CDP | 连接已有浏览器(如http://127.0.0.1:9222),在其中新建隔离 context | 只关闭自己创建的 context,不动已有会话与标签页 |
CALENDAR_TEAM_BROWSER_URL | 覆盖预览服务的 origin | 默认http://127.0.0.1:3037 |
MODE | 构建模式 | 命令中显式传development |
原文档特别说明:不会修改已有浏览器 context、会话或栈。这在 CI 与本地调试并存时非常关键——脚本不会关闭你正在用的浏览器,只会关闭它自己创建的 context(if (!sharedCdp) await browser.close();)。
截图统一输出到/tmp/calendar-team-*.png:网格、OOO 隐藏态、设置页、设置修改后以及最终态各一张(calendar-team-grid.png、calendar-team-ooo-hidden.png、calendar-team-settings.png、calendar-team-settings-after.png、calendar-team-final.png)。
三、隔离机制:如何做到"完整应用 + 零真实依赖"
3.1 构建期:用 Vite 配置锁定合成环境
vite.config.ts 在加载时直接Object.assign(process.env, {...}),把运行环境钉死为一组合成值:
Object.assign(process.env, { MODE: 'development', VITE_LOCAL_SERVERS: 'ALL', VITE_LOCAL_BACKEND_ORIGIN: 'same-origin', VITE_ENABLE_CALENDAR_UI: 'true', VITE_ENABLE_CALENDAR_TEAM_SHARING: 'true', VITE_ENABLE_CALENDAR_TEAM_OOO: 'true', VITE_ENABLE_GRAPHQL_CALENDAR: 'false', VITE_DISABLE_BROWSER_TURSO_CACHE: 'true', VITE_ENABLE_IN_APP_TOURS: 'false', VITE_ENABLE_BROWSER_OTEL: 'false', });这组变量的含义可以从前端源码反推:
VITE_ENABLE_CALENDAR_TEAM_SHARING/VITE_ENABLE_CALENDAR_TEAM_OOO对应 featureFlags.ts 中的enableCalendarTeamSharing与enableCalendarTeamOoo。两者默认值是onInDev(开发态默认开启),套件里再显式置true,避免将来默认值调整导致测试失效。注释同时说明 OOO 是"纯增量"功能:关闭时该区块根本不挂载,也不会发起团队休假查询。VITE_ENABLE_GRAPHQL_CALENDAR=false把日历数据固定走 REST,呼应文档中"own-calendar reads use the existing REST fallback"。VITE_DISABLE_BROWSER_TURSO_CACHE=true关闭本地 Turso 缓存,避免浏览器内持久化状态跨运行污染断言。VITE_LOCAL_SERVERS=ALL+VITE_LOCAL_BACKEND_ORIGIN=same-origin:所有服务客户端都指向当前 origin,这样page.route('**/*')才能用一个处理器统一接管。
配置通过mergeConfig(createAppViteConfig()(env), {...})合并,说明它复用了应用的主 Vite 配置,因此测的是真实构建产物而非精简夹具;同时把envDir指向测试目录、cacheDir指向node_modules/.vite-calendar-team-browser,不会干扰日常开发缓存。
3.2 运行期:cookie、偏好与网络三层拦截
browser.ts 在导航前完成四件事:
- 伪造登录 cookie:
login=true,仅对测试 origin 生效。 - 预置日历偏好:通过
addInitScript写入macro:pref:calendar:settings,包含periodView: 'timeGridWeek'、空的hiddenSourceIds、showWeekends: true、weekStartsOn: 0、timeFormat: '24-hour'。这一步让后续"隐藏/恢复来源"的断言有确定的初始状态。 - 关闭所有 WebSocket:
page.routeWebSocket('**/*', (socket) => socket.close())。这是"阻断 websocket 流量"的落地方式——不是让连接失败,而是让它立即关闭,从而验证界面在无实时通道时的降级表现。 - 阻断非本地请求:路由处理器第一行
if (url.origin !== origin) return route.abort();,与文档中的"blocks nonlocal requests"一致。
此外还挂了三个观测钩子:pageerror收集页面异常、requestfailed只记录同源且非net::ERR_ABORTED的失败(注释说明导航与刻意的过期检查会取消 fetch)、unknown记录所有未被显式 mock 的请求路径,作为诊断输出的一部分。
3.3 实时时钟与合成日期
与许多固定Date的夹具不同,这里保留浏览器真实时钟,原因写在注释里:日历加载过渡会比对流逝时间(// Keep the browser clock live: calendar loading transitions compare elapsed time.)。但事件数据仍需要稳定的日期锚点,因此用当前日期生成fixtureDate,并派生出次日nextDate,所有事件时间都以${fixtureDate}T${hour}:00:00Z构造,时区固定UTC、视口1500×1100。
唯一依赖真实时间的是"Upcoming"断言:只有当Date.now()早于事件开始时间时才检查该按钮,避免脚本在事件时刻之后运行时出现假失败。
四、被拦截的 API 契约
路由处理器构成了一份可执行的接口契约,理解它等于理解了前端团队日历依赖的数据形状:
| 路径 | 方法 | 返回/行为 |
|---|---|---|
/auth/jwt/refresh | 任意 | {},让会话保持"已刷新" |
/auth/team | GET | 团队00000000-...-1111,成员含本人、Alice、Bob,均为member/free |
/auth/user/get_names_with_email | GET | 三个用户的姓名,用于getDisplayName渲染 "Bob Fixture" |
/calendar/team-sharing | GET / PUT | 读写sharing,PUT 会写入mutations[] |
/calendar/availability-calendars | GET | 两个日历:primary(计入)、followed(不计入) |
/calendar/availability-calendars/:id | PATCH/PUT | 返回 204,并更新included与mutations[] |
/calendar/calendars | GET | 两个来源,同一emailAddress,用于账号分组 |
/dss/calendar-events/team | GET | 固定 404,并记录查询串到teamReads[] |
/dss/calendar-events/team-out-of-office | GET | 返回 Bob 的全天 OOO 条目 |
/calendar/events/:id | PATCH | 记录eventPatch,回写标题 |
/dss/calendar-events | GET | 返回点事件ownPoint,syncStatus: 'ready' |
其余未命中的路径交给bootstrapResponses(来自 apps/web/tests/native/fixtures/bootstrap.ts)兜底,仍未命中则记入unknown并返回 404,而不是静默通过。
注意/dss/calendar-events/team被故意设成 404:这不是占位,而是断言的一部分——脚本要证明没有任何界面会去读取队友的完整日历。
五、断言逐条拆解
5.1 团队日历入口必须不存在
进入/app/calendar/week后,先等待Imported point出现,再执行expectRevealedCalendar():
await expect(activePage.locator('[data-calendar-loading-skeleton]')) .toHaveCount(0, { timeout: 15000 }); await expect(activePage.locator( '[data-calendar-loading-state]:not([data-calendar-loading-state="hidden"])' )).toHaveCount(0, { timeout: 15000 });注释点出一个真实陷阱:Playwright 的可见性检查接受opacity: 0,仅断言 DOM 里有标题,可能在 FullCalendar 用骨架遮蔽芯片时依然通过。因此这里改为断言骨架节点数量为 0、加载状态属性不在非 hidden 状态。
随后检查侧边栏(role=complementary,名称Calendar navigation)存在Calendars标题,但页面上不存在Team calendars文本,也不存在Show team calendars开关。这与源码一致:当前实现是"只读的团队投影 + 休假叠加层",并没有"整块团队日历"开关。
5.2 点事件:紧凑渲染、单一时间、不可拖拽
ownPoint的time通过pointTime()构造,endsAt直接复用startsAt,即零时长。断言如下:
- 芯片高度
> 0 && < 30,注释直白地写着Point must have a compact footprint, not an invented hour,防止把点事件渲染成一个小时的块; - 不带
fc-event-draggable/fc-event-resizable类; - 文本包含
11:00,不包含12:00——只能显示一个时间点; - 若当前时间早于事件时刻,
Open Imported point按钮应可见,注释说明"Upcoming 使用精确的点时刻,绝不当作进行中"; - 点击后出现
/No duration/提示。
5.3 编辑标题必须省略时间字段
这是整套测试里最有价值的一条契约:
- 打开编辑,填入
Imported point updated; - 选择
Add meeting link → Macro call; - 点击保存,断言出现
Give this event a duration before adding a Macro call.; - 关键:此时
assert.deepEqual(mutations, [], 'Rejected point call must not save calendar changes')——校验失败必须在任何变更发出之前拦截; - 再把链接改回
No meeting link并保存; - 断言 PATCH 的
eventPatch.title === 'Imported point updated',且'time' in eventPatch === false。
也就是说,编辑一个点事件的标题,请求体里不能出现 time 字段,否则服务端可能把一个瞬时点"补"成一个时长区间。
5.4 团队休假叠加层:隐藏网格、保留列表、持久化
侧边栏中的复选框Show team out of office on the calendar与网格上的Bob Fixture: Out of office芯片联动:
- 初始两者都可见,侧边栏列表行
Bob Fixture也在; - 点击
Show on calendar后,复选框取消、芯片计数为 0,但列表行仍然可见; localStorage的hiddenSourceIds必须精确等于['team-ooo'];- 再点一次,复选框恢复、芯片恢复。
这条断言与源码强对应:types.ts 定义TEAM_OOO_SOURCE_ID = 'team-ooo',use-team-ooo.ts 则用teamOooSourceId(ownerId)生成team-ooo:<ownerId>作为单个同事的可见性来源 id,并把整个 OOO 集合归入sourceCalendarIds: [TEAM_OOO_SOURCE_ID]。因此"隐藏"作用于展示层来源,而不是删除数据——这解释了为什么侧边栏列表还保留那一行。
同一文件还揭示了 OOO 映射的关键细节:当团队共享策略为busy_only时,服务端不下发标题,前端回退为Out of office,并通过teamProjection.kind区分busy与details;标题最终拼成${name}: ${title},即界面上看到的Bob Fixture: Out of office。
5.5 设置页:共享策略、账号分组与三态复选框
导航到/app/settings/calendar后:
Share with teammates下拉初始显示Busy blocks,并展示说明文案Teammates see when you are busy, not what.;- 页面上依然不存在
Show team calendars开关; - 定位到包含标题
Calendars that count as busy的最内层section(注释提示页面存在 section 嵌套,所以用.last()); - 账号复选框
offline@example.com处于不确定态(indeterminate === true),因为两个子日历一个计入、一个不计入; - 展开账号后,
My primary勾选、Subscribed coworker未勾选。
这三个选项的文案与 id 直接来自 team-sharing-settings.tsx:
| id | 名称 | 说明 |
|---|---|---|
busy_only | Busy blocks | Teammates see when you are busy, not what. |
all | Event details | Teammates see your events. Private events stay busy blocks. |
none | Nothing | Teammates cannot see your calendar or availability. |
三态逻辑则由 SourceControls.tsx 提供:按账号分组(groupCalendarSourcesByAccount)、全选/部分选决定checked/indeterminate、以及Expand/Collapse <email>按钮的无障碍名称。分组数据本身由 availability.ts 的availabilitySources()生成——优先沿用侧边栏已知来源的顺序与颜色,未知来源才追加默认颜色,这解释了测试里这两个日历为什么能稳定地按账号聚合。
5.6 修改设置不能闪出加载态
在改动共享策略与忙碌日历之前,脚本先注入watchLoadingText():用MutationObserver监听document.body的childList/subtree/characterData,只要文本中出现Loading your team…、Loading calendars…、Loading team calendar settings…三者之一就记录一次。随后完成一系列修改:
- 共享策略
Busy blocks → Event details,断言sharing === 'all'且说明文案变为Teammates see your events. Private events stay busy blocks.; → Nothing,断言sharing === 'none';- 勾选
Subscribed coworker,断言included.followed === true,账号复选框随之被勾选; - 点击账号名取消整组,断言
included.primary === false && included.followed === false,账号复选框取消; - 回到
Busy blocks,断言sharing === 'busy_only'; waitForTimeout(1500)等待保存后的 refetch 落定,最后:
assert.deepEqual(await loadingSeen(), [], 'Changing a setting must not flash a loading state');这些文案与 team-sharing-settings.tsx 的实际渲染分支一一对应:只在teamLoading时显示Loading your team…,只在calendarsLoading时显示Loading calendars…,calendar-team.tsx里则是Loading team calendar settings…。因此"不闪加载"等价于"修改设置时不应把已加载的查询退回 pending 状态"——例如乐观更新或保留既有数据,而不是重置整个 suspense 边界。
5.7 收尾断言与退出码
try/catch/finally结构保证无论成功失败都会输出诊断 JSON:
{ "errors": [], "requestFailures": [], "unknown": [], "teamReads": [], "mutations": [] }同时满足三条断言:
assert.deepEqual(teamReads, [], 'No surface reads teammate calendars');assert.deepEqual(errors, [])(无未捕获页面异常);assert.deepEqual(requestFailures, [])(同源请求无失败)。
失败时process.exitCode = 1;在 CDP 共享模式下,finally里用process.exit(...)显式退出,避免残留的连接阻止进程结束。关闭 context 前调用page.removeAllListeners('requestfailed'),注释说明了原因:关闭 context 会取消无关预加载,那是清理而非被测行为的失败。
六、与单元测试的分工
这套浏览器脚本并不孤立存在。它对应的纯逻辑部分有专门的单测:
- use-team-ooo.test.tsx 验证
hiddenSourceIds含team-ooo时叠加层被过滤、恢复后重新出现; - preferences.test.ts 验证
hiddenSourceIds: ['team-ooo', 'holidays']的读写; - merge.test.ts 用
team-ooo:bob作为芯片来源; - team-cache.test.ts 覆盖 OOO 查询键的失效逻辑。
分工可以概括为:单测锁定数据映射、缓存键与过滤规则;浏览器套件锁定"DON 是否渲染、点击是否生效、请求是否发出、是否闪烁"这类只有真实 DOM 与真实路由才能证明的行为。
七、数据加载策略:为什么这套前端值得用浏览器测
team-ooo.ts 展示了 OOO 数据的加载设计,它直接影响浏览器层的可观测行为:
- 固定 91 天窗口:
use-team-ooo.ts中TEAM_OOO_WINDOW_DAYS = 91,以 2024-01-01 这个"固定的本地周一"为纪元,任何视口都被换算成整窗口请求,日历分页与侧边栏因此共享同一批请求,而不是各自轮询自己的范围; - 分页与二分拆分:单次
limit = 2000,若响应hasMore为真,就把范围切成两半递归拉取,跨切分点的事件按[eventId, occurrenceKey]去重; - 统一轮询:
TEAM_OOO_POLL_INTERVAL = 30_000,用全局定时器 +queryClient.invalidateQueries代替 per-query 的refetchInterval,避免同一范围被多个 observer 重复拉取;窗口隐藏时(!focusManager.isFocused())跳过。
这些机制意味着测试里只需要 mock 一个/dss/calendar-events/team-out-of-office就能驱动整个叠加层,也意味着请求次数本身是可断言的资源——如果将来有人改成按视口逐页请求,缓存键与轮询逻辑都会变化,而浏览器套件的unknown列表与requestFailures会是第一道警报。
八、已知边界与扩展建议
必须再次强调原文档明确写下的三条限制,避免误用结论:
- 不验证 Rust 服务。脚本里
/dss/calendar-events/team-out-of-office返回的是手写的bobOutOfOffice,服务端的可用性计算、权限判定完全不在覆盖范围内。 - 不验证 Google 权限。设置页文案明确写着 "Google Calendar permissions stay the same",但脚本并不检查任何 OAuth 授权态。
- 不覆盖真实会话。登录靠 cookie + mock 的
/auth/jwt/refresh,任何依赖真实令牌链路的缺陷都不会暴露。
在此前提下,套件有几个自然的扩展方向:
- 想验证多人叠加,可把
bobOutOfOffice扩展为数组,并同步更新get_names_with_email,注意侧边栏列表行与芯片数量的断言需要成对调整; - 想验证
Event details策略下标题可见、busy_only下回退为Out of office,可让 mock 依据当前sharing变量决定是否返回title——use-team-ooo.ts 正是按item.title === undefined决定teamProjection.kind的; - 想扩展成 CI 任务,注意
strictPort: true与固定/tmp输出目录在并发执行时会冲突,需要为每个作业指定独立端口与输出路径; - 排查失败时优先看
finally打印的unknown与teamReads:前者是"漏 mock 的接口",后者非空则说明某个界面真的去读了队友日历。
九、复现清单
若要在本地完整复现一次验证,按下面顺序执行,任一步失败都不必继续:
- 确认
apps/web下依赖已安装,Chromium 可用(或设CHROMIUM_PATH); MODE=development bunx vite build -c tests/calendar-team/vite.config.ts,产物落在/tmp/calendar-team-browser-dist;bunx vite preview -c tests/calendar-team/vite.config.ts --outDir /tmp/calendar-team-browser-dist --base /app --port 3037;- 另开终端执行
bun build tests/calendar-team/browser.ts --target=node --packages=external --outfile=node_modules/.calendar-team-browser/browser.mjs; node node_modules/.calendar-team-browser/browser.mjs,成功时打印PASS: imported point rendering and metadata-only edit, team out-of-office overlay checkbox, no team calendar overlay or controls, team sharing select, account-grouped busy calendars, and settings changes without a loading flash;- 查看
/tmp/calendar-team-*.png与终端末尾的 JSON,确认errors、requestFailures、teamReads均为空数组。
这套工具的价值不在于"跑通一次",而在于它把若干容易被忽视的产品契约固化成了可执行断言:点事件不虚构成一小时、改标题不带时间、拒绝操作不落库、隐藏叠加层不清列表、切设置不闪加载、任何界面都不读队友日历。这些正是团队日历类功能最容易在迭代中悄悄退化的地方。
- 后端
- 前端
- 协同办公
- 即时通讯
- 企业应用
- CRM
- AI Agent
- 人工智能
【免费下载链接】macro
Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.
相关推荐
Nuclide折叠状态共享:团队代码浏览体验一致化
Nuclide折叠状态共享:团队代码浏览体验一致化 引言 在团队协作开发过程中,代码浏览体验的一致性对于提高工作效率至关重要。Nuclide作为一款基于Atom
开发工具agno Team Brain:用一个 MCP 端点和 Token 身份构建全团队共享决策日志(附测试验证记录与源码解析)
agno Team Brain:用一个 MCP 端点和 Token 身份构建全团队共享决策日志(附测试验证记录与源码解析) 本文以 cookbook/examp
人工智能AI AgentAgent 框架多智能体工具调用RAGAgent 记忆Agent 工作流后端深入 Macro chat crate:AI 会话领域模型、Kafka 事件契约与团队共享边界
深入 Macro chat crate:AI 会话领域模型、Kafka 事件契约与团队共享边界 crates/chat 是 Macro 统一工作空间中负责 AI
后端前端协同办公即时通讯企业应用CRMAI Agent人工智能
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考