Macro 团队日历浏览器验证套件:用合成身份端到端校验团队共享、OOO 叠加与点事件渲染

发布时间:2026/10/30 4:38:22
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.

项目地址:https://gitcode.com/GitHub_Trending/macro3/macro
点击查看免费下载

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

这里有两个容易被忽略的细节:

  1. 构建输出目录必须与预览目录一致。配置文件里build.outDir已固定为/tmp/calendar-team-browser-dist,preview也固定为127.0.0.1:3037且strictPort: true(端口被占用时直接失败而不是漂移)。命令行再传一次--outDir/--base/--port是显式冗余,避免误用其他配置。
  2. 浏览器脚本要打成一个独立的 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 在导航前完成四件事:

  1. 伪造登录 cookie:login=true,仅对测试 origin 生效。
  2. 预置日历偏好:通过addInitScript写入macro:pref:calendar:settings,包含periodView: 'timeGridWeek'、空的hiddenSourceIds、showWeekends: true、weekStartsOn: 0、timeFormat: '24-hour'。这一步让后续"隐藏/恢复来源"的断言有确定的初始状态。
  3. 关闭所有 WebSocket:page.routeWebSocket('**/*', (socket) => socket.close())。这是"阻断 websocket 流量"的落地方式——不是让连接失败,而是让它立即关闭,从而验证界面在无实时通道时的降级表现。
  4. 阻断非本地请求:路由处理器第一行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/teamGET团队00000000-...-1111,成员含本人、Alice、Bob,均为member/free
/auth/user/get_names_with_emailGET三个用户的姓名,用于getDisplayName渲染 "Bob Fixture"
/calendar/team-sharingGET / PUT读写sharing,PUT 会写入mutations[]
/calendar/availability-calendarsGET两个日历:primary(计入)、followed(不计入)
/calendar/availability-calendars/:idPATCH/PUT返回 204,并更新included与mutations[]
/calendar/calendarsGET两个来源,同一emailAddress,用于账号分组
/dss/calendar-events/teamGET固定 404,并记录查询串到teamReads[]
/dss/calendar-events/team-out-of-officeGET返回 Bob 的全天 OOO 条目
/calendar/events/:idPATCH记录eventPatch,回写标题
/dss/calendar-eventsGET返回点事件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 编辑标题必须省略时间字段

这是整套测试里最有价值的一条契约:

  1. 打开编辑,填入Imported point updated;
  2. 选择Add meeting link → Macro call;
  3. 点击保存,断言出现Give this event a duration before adding a Macro call.;
  4. 关键:此时assert.deepEqual(mutations, [], 'Rejected point call must not save calendar changes')——校验失败必须在任何变更发出之前拦截;
  5. 再把链接改回No meeting link并保存;
  6. 断言 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后:

  1. Share with teammates下拉初始显示Busy blocks,并展示说明文案Teammates see when you are busy, not what.;
  2. 页面上依然不存在Show team calendars开关;
  3. 定位到包含标题Calendars that count as busy的最内层section(注释提示页面存在 section 嵌套,所以用.last());
  4. 账号复选框offline@example.com处于不确定态(indeterminate === true),因为两个子日历一个计入、一个不计入;
  5. 展开账号后,My primary勾选、Subscribed coworker未勾选。

这三个选项的文案与 id 直接来自 team-sharing-settings.tsx:

id名称说明
busy_onlyBusy blocksTeammates see when you are busy, not what.
allEvent detailsTeammates see your events. Private events stay busy blocks.
noneNothingTeammates 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会是第一道警报。

八、已知边界与扩展建议

必须再次强调原文档明确写下的三条限制,避免误用结论:

  1. 不验证 Rust 服务。脚本里/dss/calendar-events/team-out-of-office返回的是手写的bobOutOfOffice,服务端的可用性计算、权限判定完全不在覆盖范围内。
  2. 不验证 Google 权限。设置页文案明确写着 "Google Calendar permissions stay the same",但脚本并不检查任何 OAuth 授权态。
  3. 不覆盖真实会话。登录靠 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 的接口",后者非空则说明某个界面真的去读了队友日历。

九、复现清单

若要在本地完整复现一次验证,按下面顺序执行,任一步失败都不必继续:

  1. 确认apps/web下依赖已安装,Chromium 可用(或设CHROMIUM_PATH);
  2. MODE=development bunx vite build -c tests/calendar-team/vite.config.ts,产物落在/tmp/calendar-team-browser-dist;
  3. bunx vite preview -c tests/calendar-team/vite.config.ts --outDir /tmp/calendar-team-browser-dist --base /app --port 3037;
  4. 另开终端执行bun build tests/calendar-team/browser.ts --target=node --packages=external --outfile=node_modules/.calendar-team-browser/browser.mjs;
  5. 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;
  6. 查看/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.

项目地址:https://gitcode.com/GitHub_Trending/macro3/macro
点击查看免费下载

相关推荐

上一篇:Flex:ai生产环境部署完整清单:从openEuler到K8s集群的12步落地指南
下一篇:训练策略模型的关键帧技巧:IB-Robot 帧检测与训练权重机制详解

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考