diff --git a/electron-builder.send-runtime.yml b/electron-builder.send-runtime.yml new file mode 100644 index 0000000..5162a30 --- /dev/null +++ b/electron-builder.send-runtime.yml @@ -0,0 +1,16 @@ +extends: ./electron-builder.yml +extraResources: + - from: build/app-update.yml + to: app-update.yml + - from: resources + to: resources + filter: + - '**/*' + - from: docs/skill/tracememo-reader + to: skill/tracememo-reader + filter: + - '**/*' +mac: + target: + - dmg + - zip diff --git a/electron-builder.yml b/electron-builder.yml index 1e83eb5..49a1292 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -34,6 +34,7 @@ extraResources: to: resources filter: - '**/*' + - '!runtime/darwin-arm64/**' - from: docs/skill/tracememo-reader to: skill/tracememo-reader filter: diff --git a/package.json b/package.json index d374a97..875629f 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir", "build:win": "npm run typecheck && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64", "build:mac:arm64": "npm run typecheck && npm run prepare:ffmpeg:mac:arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64", + "build:mac:arm64:send-runtime": "npm run typecheck && npm run prepare:ffmpeg:mac:arm64 && electron-vite build && cross-env TM_SEND_RUNTIME_BUILD=1 electron-builder --config electron-builder.send-runtime.yml --mac --arm64", "build:mac:x64": "npm run typecheck && npm run prepare:ffmpeg:mac:x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64", "release": "npm run release:mac && npm run release:win", "release:mac": "npm run typecheck && electron-vite build && npm run release:mac:arm64 && npm run release:mac:x64", diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 343d328..7ff4dd5 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -325,9 +325,70 @@ function pruneForeignArchConnectors(runtimeResources, platform, arch) { return removed } +/** + * 微信发送运行时打包边界 + */ +const SEND_RUNTIME_RELATIVE = ['resources', 'runtime', 'darwin-arm64'] +const SEND_RUNTIME_ENTRY = 'tm-wechat-host' + +function sendRuntimeLocations(runtimeResources) { + return [ + path.join(runtimeResources, ...SEND_RUNTIME_RELATIVE), + path.join(runtimeResources, 'app.asar.unpacked', ...SEND_RUNTIME_RELATIVE) + ] +} + +function findSendRuntime(runtimeResources) { + return ( + sendRuntimeLocations(runtimeResources).find((directory) => + existsSync(path.join(directory, SEND_RUNTIME_ENTRY)) + ) || null + ) +} + +function isSendRuntimeBuild() { + return process.env.TM_SEND_RUNTIME_BUILD === '1' +} + +function enforceSendRuntimeBoundary( + runtimeResources, + platform, + bundlesSendRuntime = isSendRuntimeBuild() +) { + if (platform !== 'darwin') return null + const found = findSendRuntime(runtimeResources) + if (bundlesSendRuntime) { + if (!found) { + throw new Error( + 'This macOS build requires the WeChat send runtime but resources/runtime/darwin-arm64 is missing. ' + + 'Run `pnpm prepare:wechat-native` first, or point TM_NATIVE_RUNTIME_DIR at the artifact.' + ) + } + return found + } + if (found) { + throw new Error( + 'macOS bundle must not include the WeChat send runtime: ' + + found + + '. Build with `pnpm build:mac:arm64:send-runtime` (TM_SEND_RUNTIME_BUILD=1) when it is required, ' + + 'or fix the resources filter in electron-builder.yml.' + ) + } + return null +} + exports.default = async function afterPack(context) { const runtimeResources = getRuntimeResources(context) const arch = normalizeBuilderArch(context.arch) + // 边界先判,越早失败越好。 + const sendRuntime = enforceSendRuntimeBoundary(runtimeResources, context.electronPlatformName) + if (context.electronPlatformName === 'darwin') { + console.log( + sendRuntime + ? `[afterPack] send runtime bundled at ${sendRuntime}` + : '[afterPack] send runtime excluded' + ) + } validateAsarRuntimeDependencies(runtimeResources) validateReaderSkillRuntime(runtimeResources) validateSilkWasmRuntime(runtimeResources) @@ -386,3 +447,6 @@ exports.findMacosHelperPaths = findMacosHelperPaths exports.isMacosCodeValid = isMacosCodeValid exports.signMacosHelpers = signMacosHelpers exports.signMacosAppBundle = signMacosAppBundle +exports.sendRuntimeLocations = sendRuntimeLocations +exports.findSendRuntime = findSendRuntime +exports.enforceSendRuntimeBoundary = enforceSendRuntimeBoundary diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index bd40fce..b69d054 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -197,15 +197,6 @@ test('NAV-04 exit monitor management can save an empty scope and show the setup await expect(fixture.page.getByRole('heading', { name: '管理群聊', exact: true })).toBeVisible() await expect(fixture.page.getByText('发送能力已就绪', { exact: true })).toBeVisible() await expect(fixture.page.getByRole('checkbox', { name: '监控产品测试群' })).toBeChecked() - await fixture.page.getByRole('button', { name: '查看退群监测模板' }).click() - await expect(fixture.page.getByLabel('退群监测模板内容')).toHaveValue(/用户: \{user\}/) - await expect(fixture.page.getByLabel('退群监测模板内容')).toHaveValue(/群备注: \{groupRemark\}/) - const customTemplate = '[退群监测]\n用户: {user}\n群备注: {groupRemark}' - await fixture.page.getByLabel('退群监测模板内容').fill(customTemplate) - await fixture.page.getByRole('button', { name: '保存模板' }).click() - await fixture.page.getByRole('button', { name: '查看退群监测模板' }).click() - await expect(fixture.page.getByLabel('退群监测模板内容')).toHaveValue(customTemplate) - await fixture.page.keyboard.press('Escape') await fixture.page.getByRole('checkbox', { name: '监控产品测试群' }).click() await fixture.page.getByRole('checkbox', { name: '监控折叠群聊样本' }).click() await fixture.page.getByRole('button', { name: '保存监控群聊' }).click() @@ -216,6 +207,46 @@ test('NAV-04 exit monitor management can save an empty scope and show the setup } }) +/** + * 退群通知的**内容模板**已随「退群通知」一起迁进自动化,管理群聊页只负责 + * 「监测哪些群」。这条用例锁住新家:模板可编辑、可保存,且真的落库。 + */ +test('NAV-05 leave notification template is editable in the automation workspace', async () => { + const fixture = await launchTestApp() + const pageErrors: Error[] = [] + fixture.page.on('pageerror', (error) => pageErrors.push(error)) + try { + await fixture.page.getByRole('button', { name: '退群监控' }).click() + await fixture.page.getByRole('button', { name: '配置退群通知自动化' }).click() + + await expect(fixture.page.getByRole('heading', { name: '自动化', exact: true })).toBeVisible() + await expect(fixture.page.getByRole('heading', { name: '退群通知' })).toBeVisible() + + const template = fixture.page.getByLabel('退群通知模板内容') + await expect(template).toHaveValue(/用户: \{user\}/) + await expect(template).toHaveValue(/群备注: \{groupRemark\}/) + + const customTemplate = '[退群监测]\n用户: {user}\n群备注: {groupRemark}' + await template.fill(customTemplate) + await fixture.page.getByRole('button', { name: '保存' }).click() + /* + * `exact: true` 不能省:Radix 额外渲染一个 `role="status"` 的无障碍播报节点, + * 文本是 `Notification 已保存「退群通知」`,且由 `useNextFrame` **下一帧才填** —— + * 裸 regex 会命中它造成 strict mode violation,且是否命中取决于帧时序。 + */ + await expect(fixture.page.getByText('已保存「退群通知」', { exact: true })).toBeVisible() + + await fixture.page.reload() + await fixture.page.getByRole('button', { name: '退群监控' }).click() + await fixture.page.getByRole('button', { name: '配置退群通知自动化' }).click() + await expect(fixture.page.getByLabel('退群通知模板内容')).toHaveValue(customTemplate) + + expect(pageErrors).toEqual([]) + } finally { + await fixture.close() + } +}) + test('CHAT-01 archive More menu is keyboard-safe and keeps the page usable', async () => { const fixture = await launchTestApp() const pageErrors: Error[] = [] @@ -773,7 +804,7 @@ test('AGENT-01 Agent Hub controls stay usable in the default offline layout', as await fixture.page.keyboard.press('Escape') await expect(logSource).toBeFocused() await expect(fixture.page.getByRole('button', { name: '复制日志' })).toBeDisabled() - await fixture.page.getByRole('button', { name: '清空' }).click() + await fixture.page.getByRole('button', { name: '清空', exact: true }).click() expect( await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) diff --git a/tests/e2e/scheduled-reports.spec.ts b/tests/e2e/scheduled-reports.spec.ts index 829a52f..42903d7 100644 --- a/tests/e2e/scheduled-reports.spec.ts +++ b/tests/e2e/scheduled-reports.spec.ts @@ -1,80 +1,146 @@ -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' import { launchTestApp } from './support/electron' -const debugNotificationEnabled = ['1', 'true', 'yes', 'on'].includes( - String(process.env.VITE_SCHEDULED_REPORT_DEBUG || '') - .trim() - .toLowerCase() -) +/** + * 定时日报的 E2E。 + * + * 「定时日报」已从「日报 → 定时日报」独立页迁到「自动化 → 规则 → 定时日报」, + * 本文件走的是**迁移后**的真实路径: + * + * 日报(提示条)→「去自动化配置 →」→ 自动化 · 定时日报(规则列表)→ 新建 → 编辑器 + * + * 断言一律打在真实模型渲染出的**默认值与真实选项**上(范围 / 模板 / 成员名称 / + * 消息类型 / 发送目标),而不是「页面上有个按钮」这种空转检查。 + */ -test('SCHEDULED-REPORT-UI-01 opens the scheduled report dialog without viewport overflow', async () => { +/** fixture 约定的固定时刻:2026-08-27 08:30 (+08:00)。 */ +const FIXTURE_NOW = Date.parse('2026-08-27T08:30:00+08:00') + +/** 从时间线进入「自动化 → 定时日报」的规则列表。 */ +async function openScheduledReportRuleList(page: Page): Promise { + await page.getByRole('button', { name: '日报' }).click() + await page.getByRole('button', { name: '去自动化配置 →' }).click() + await expect(page.getByRole('radiogroup', { name: '切换规则类型' })).toBeVisible() +} + +/** 列表 → 新建 → 编辑器。 */ +async function openScheduledReportEditor(page: Page): Promise { + await openScheduledReportRuleList(page) + await page.getByRole('button', { name: '+ 新建定时日报' }).first().click() + await expect(page.getByRole('heading', { name: '新建定时日报' })).toBeVisible() +} + +test('SCHEDULED-REPORT-UI-01 creates a scheduled report rule from the automation workspace without viewport overflow', async () => { test.skip( process.platform !== 'darwin' && process.platform !== 'win32', 'The scheduled report send capability requires macOS or Windows' ) - const fixture = await launchTestApp({ now: Date.parse('2026-08-27T08:30:00+08:00') }) + const fixture = await launchTestApp({ now: FIXTURE_NOW }) const pageErrors: Error[] = [] fixture.page.on('pageerror', (error) => pageErrors.push(error)) + const page = fixture.page try { - await fixture.page.getByRole('button', { name: '日报' }).click() - await fixture.page.getByRole('tab', { name: '定时日报' }).click() - await expect(fixture.page.getByRole('heading', { name: '定时日报', exact: true })).toBeVisible() - await expect(fixture.page.getByText('✓ 微信发送能力已就绪')).toBeVisible() + await openScheduledReportRuleList(page) - const createButton = fixture.page.getByRole('button', { name: /新建定时日报/ }).first() - await expect(createButton).toBeEnabled() - await createButton.click() + // 深链把「定时日报」这个类型选中,并且先给列表而不是直接甩一个空表单。 + await expect(page.getByRole('radio', { name: '定时日报' })).toBeChecked() + await expect(page.getByText('还没有定时日报')).toBeVisible() - const dialog = fixture.page.getByRole('dialog', { name: '新建定时日报' }) - await expect(dialog).toBeVisible() - await expect(dialog.getByRole('button', { name: /产品测试群 .*微信群聊/ })).toBeVisible() - await expect(dialog.getByPlaceholder('搜索群聊')).toBeVisible() - await expect(dialog.getByRole('button', { name: '创建定时日报' })).toBeVisible() + await page.getByRole('button', { name: '+ 新建定时日报' }).first().click() + await expect(page.getByRole('heading', { name: '新建定时日报' })).toBeVisible() - const customRange = dialog.locator('[aria-disabled="true"]') - await expect(customRange).toHaveCount(1) - const customLabel = customRange.getByText('自定义', { exact: true }) - const customHint = customRange.getByText('即将支持', { exact: true }) - await expect(customLabel).toHaveCSS('white-space', 'nowrap') - await expect(customHint).toHaveCSS('white-space', 'nowrap') - expect((await customLabel.boundingBox())?.height).toBeLessThanOrEqual(20) - expect((await customHint.boundingBox())?.height).toBeLessThanOrEqual(20) + // 编辑器是内嵌面板(不是弹窗),三段结构齐全。 + for (const heading of ['1 · 什么时候触发', '2 · 生成什么日报', '3 · 生成后发送到哪里']) { + await expect(page.getByRole('heading', { name: heading })).toBeVisible() + } - const selects = dialog.getByRole('combobox') - await expect(selects).toHaveCount(2) - await selects.nth(0).click() - const templateOption = fixture.page.getByRole('option', { name: /Mobile 01.*微信信息流/ }) - await expect(templateOption).toBeVisible() - await templateOption.click() - await expect(selects.nth(0)).toContainText('微信信息流') + // 触发段:默认 18:30,且「预计下次执行」按本地时区算出当日槽位。 + await expect(page.getByRole('textbox', { name: '执行时间小时' })).toHaveValue('18') + await expect(page.getByRole('textbox', { name: '执行时间分钟' })).toHaveValue('30') + await expect(page.getByTestId('scheduled-next-run')).toHaveText('今日 18:30') - await selects.nth(1).click() - const memberOption = fixture.page.getByRole('option', { name: '微信昵称' }) - await expect(memberOption).toBeVisible() - await memberOption.click({ force: true }) - await expect(selects.nth(1)).toContainText('微信昵称', { timeout: 8000 }) + // 还没填必填项就必须**说不出原因地**拦住保存。 + const saveButton = page.getByRole('button', { name: '保存', exact: true }) + await expect(saveButton).toBeDisabled() + await expect(page.getByTestId('scheduled-save-blocker')).toHaveText('请填写任务名称') + /* + * 「自定义」区间在旧页面是 aria-disabled 的占位,新编辑器直接把范围收成一个下拉 —— + * 所以这里不再断言那个占位,改成断言下拉本身接的是真实取值。 + */ + await expect(page.getByRole('combobox', { name: '日报范围' })).toHaveText('今日') + + // 来源群候选来自真实群列表,且搜索框真的过滤。 + await expect(page.getByRole('radio', { name: '产品测试群' })).toBeVisible() + await expect(page.getByRole('radio', { name: '折叠群聊样本' })).toBeVisible() + await page.getByRole('textbox', { name: '搜索群聊' }).fill('折叠') + await expect(page.getByRole('radio', { name: '产品测试群' })).toHaveCount(0) + await expect(page.getByRole('radio', { name: '折叠群聊样本' })).toBeVisible() + await page.getByRole('textbox', { name: '搜索群聊' }).fill('') + await expect(page.getByRole('radio', { name: '产品测试群' })).toBeVisible() + + // 模板下拉:默认经典日报,能切到社区模板。 + const templateSelect = page.getByRole('combobox', { name: '日报模板' }) + await expect(templateSelect).toHaveText('经典日报') + await templateSelect.click() + await page.getByRole('option', { name: 'Mobile 01 · 微信信息流' }).click() + await expect(templateSelect).toHaveText('Mobile 01 · 微信信息流') + + // 成员名称下拉:默认群昵称,能切到微信昵称。 + const memberSelect = page.getByRole('combobox', { name: '成员名称' }) + await expect(memberSelect).toHaveText('群昵称') + await memberSelect.click() + await page.getByRole('option', { name: '微信昵称' }).click() + await expect(memberSelect).toHaveText('微信昵称') + + await expect(page.getByRole('textbox', { name: '日报生成超时' })).toHaveValue('300') + + // 发送目标四选一,默认「文件传输助手」。 + await expect(page.getByRole('radio', { name: '文件传输助手' })).toBeChecked() + + // 整页不允许出现横向滚动条(布局回归的硬门槛)。 expect( - await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) ).toBe(true) - const bounds = await dialog.boundingBox() - expect(bounds).not.toBeNull() - expect(bounds!.y).toBeGreaterThanOrEqual(0) - expect(bounds!.y + bounds!.height).toBeLessThanOrEqual( - await fixture.page.evaluate(() => window.innerHeight) - ) - await dialog.getByPlaceholder('例如:技术交流 · 每日日报').fill('E2E 定时日报') - await dialog.getByRole('radio', { name: '今日' }).click() - await dialog.getByRole('button', { name: '创建定时日报' }).click() - await expect(dialog).toHaveCount(0) - await expect(fixture.page.getByText('E2E 定时日报', { exact: true })).toBeVisible() - await expect(fixture.page.getByText('今日', { exact: true })).toBeVisible() + /* + * 阻断原因是**逐级**暴露的:填了名称就轮到来源群,选了群才放行。 + * 这里按顺序走一遍,确认拦的是真实缺失项而不是一个笼统的"保存失败"。 + */ + await page.getByRole('textbox', { name: '定时日报任务名称' }).fill('E2E 定时日报') + await expect(page.getByTestId('scheduled-save-blocker')).toHaveText('请选择日报来源群') + await expect(saveButton).toBeDisabled() - await fixture.page.getByRole('button', { name: '设置' }).click() - await fixture.page.getByRole('button', { name: '微信发送', exact: true }).click() - await expect(fixture.page.getByRole('heading', { name: '微信发送' })).toBeVisible() - await expect(fixture.page.getByRole('heading', { name: '发送能力', exact: true })).toBeVisible() + await page.getByRole('radio', { name: '产品测试群' }).click() + await expect(page.getByTestId('scheduled-save-blocker')).toHaveCount(0) + await expect(saveButton).toBeEnabled() + await saveButton.click() + + // toast 必须 `exact`:Radix 的无障碍播报节点文案是 `Notification 已创建「…」`,子串会命中 2 个。 + await expect(page.getByText('已创建「E2E 定时日报」', { exact: true })).toBeVisible() + + // 回到列表,卡片渲染的是**保存回来的规则**,不是草稿。 + await expect(page.getByRole('heading', { name: 'E2E 定时日报' })).toBeVisible() + await expect(page.getByText('每天 18:30')).toBeVisible() + await expect(page.getByRole('button', { name: '+ 新建定时日报' }).first()).toBeVisible() + + // 发送能力状态仍是设置页里的同一份事实。 + await page.getByRole('button', { name: '设置' }).click() + await page.getByRole('button', { name: '微信发送', exact: true }).click() + await expect(page.getByRole('heading', { name: '微信发送' })).toBeVisible() + /* + * 这一页的区块标题按平台分叉:macOS 用「配置状态」(SetupGuide 统一展示), + * Windows 才额外有「发送能力」卡。旧断言写死了「发送能力」,在 macOS 上恒假。 + * + * `exact: true` 不能省:Windows 上同一页还有「发送能力授权」, + * 子串匹配会同时命中 —— 这正是 CI 上 strict mode violation 的来源。 + */ + await expect( + page.getByRole('heading', { + name: process.platform === 'darwin' ? '配置状态' : '发送能力', + exact: true + }) + ).toBeVisible() expect(pageErrors).toEqual([]) } finally { @@ -82,59 +148,40 @@ test('SCHEDULED-REPORT-UI-01 opens the scheduled report dialog without viewport } }) -test('SCHEDULED-REPORT-UI-02 keeps notification switch off when Agent Hub is offline', async () => { +test('SCHEDULED-REPORT-UI-02 keeps the notification switch off and explains why when Agent Hub is offline', async () => { test.skip( process.platform !== 'darwin' && process.platform !== 'win32', 'The scheduled report send capability requires macOS or Windows' ) - const fixture = await launchTestApp({ now: Date.parse('2026-08-27T08:30:00+08:00') }) + const fixture = await launchTestApp({ now: FIXTURE_NOW }) + const pageErrors: Error[] = [] + fixture.page.on('pageerror', (error) => pageErrors.push(error)) + const page = fixture.page try { - await fixture.page.getByRole('button', { name: '日报' }).click() - await fixture.page.getByRole('tab', { name: '定时日报' }).click() - const notificationSwitch = fixture.page.getByRole('switch', { name: '微信异常通知' }) - await expect(notificationSwitch).toHaveAttribute('aria-checked', 'false') - await notificationSwitch.click() + await openScheduledReportEditor(page) + + // 异常通知挂在编辑器侧栏,是「定时日报」的全局能力,不是单条规则的配置。 + await expect(page.getByRole('heading', { name: '微信异常通知' })).toBeVisible() await expect( - fixture.page - .getByRole('alert') - .getByText('需要先连接 Agent Hub 微信机器人,才能接收异常通知。') + page.getByText('需要先连接 Agent Hub 微信机器人,才能接收异常通知。') ).toBeVisible() + + /* + * Agent Hub 离线 ⇒ 开关**禁用**(不是"能点但点了没反应")。 + * 迁移前这里是个点了会弹报错的开关;新实现把不可用前置成禁用 + 原因 + 去处, + * 所以断言的是禁用状态与原因文案,而不是点击后的错误提示。 + */ + const notificationSwitch = page.getByRole('switch', { name: '开启微信异常通知' }) await expect(notificationSwitch).toHaveAttribute('aria-checked', 'false') - await fixture.page.getByRole('button', { name: '前往 Agent Hub' }).click() - await expect( - fixture.page.getByRole('heading', { name: 'Agent Hub', exact: true }) - ).toBeVisible() - } finally { - await fixture.close() - } -}) + await expect(notificationSwitch).toBeDisabled() -test('SCHEDULED-REPORT-UI-03 sends a simulated error notification in debug mode', async () => { - test.skip( - (process.platform !== 'darwin' && process.platform !== 'win32') || !debugNotificationEnabled, - 'The scheduled report debug action requires a supported platform and VITE_SCHEDULED_REPORT_DEBUG=true' - ) - const fixture = await launchTestApp({ now: Date.parse('2026-08-27T08:30:00+08:00') }) - try { - await fixture.page.getByRole('button', { name: '日报' }).click() - await fixture.page.getByRole('tab', { name: '定时日报' }).click() - await fixture.page - .getByRole('button', { name: /新建定时日报/ }) - .first() - .click() + // 能力未就绪时不给「发送测试」—— 不给一个点了必然失败的按钮。 + await expect(page.getByRole('button', { name: '发送测试' })).toHaveCount(0) - const dialog = fixture.page.getByRole('dialog', { name: '新建定时日报' }) - await dialog.getByPlaceholder('例如:技术交流 · 每日日报').fill('调试定时日报') - await dialog.getByRole('button', { name: '创建定时日报' }).click() - await expect(dialog).toHaveCount(0) + await page.getByRole('button', { name: '去连接 Agent Hub' }).click() + await expect(page.getByRole('heading', { name: 'Agent Hub', exact: true })).toBeVisible() - const debugButton = fixture.page.getByRole('button', { name: '测试错误信息发送' }) - await expect(debugButton).toBeVisible() - await debugButton.click() - await expect( - fixture.page.getByText('测试错误信息已发送到 Agent Hub 微信通知接收者') - ).toBeVisible() - await expect(fixture.page.getByText('定时日报错误通知测试')).toBeVisible() + expect(pageErrors).toEqual([]) } finally { await fixture.close() } diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs index 2889e01..0eb360f 100644 --- a/tests/e2e/support/electron-main.cjs +++ b/tests/e2e/support/electron-main.cjs @@ -445,10 +445,106 @@ handle('automation:getStatus', () => ({ message: connected ? '个人微信发送能力已就绪' : '尚未绑定个人微信发送能力' } })) +/* + * 定时日报是 `ruleType === 'scheduled_report'` 的**真规则**:列表由 + * `automation:listRules` 过滤得到,创建 / 更新走 `automation:createRule` / + * `automation:updateRule`。fixture 必须实现这组通道,否则保存后列表读不回来。 + */ +const scheduledReportRules = [] +let automationRuleSequence = 0 +// 「微信异常通知」是全局开关,新旧两套通道共用同一份状态。 +let scheduledReportNotificationEnabled = false handle('automation:listRules', () => [ builtinDailyReportRule(), - structuredClone(leaveNotificationRule) + structuredClone(leaveNotificationRule), + ...scheduledReportRules.map((rule) => structuredClone(rule)) ]) +handle('automation:createRule', (draft) => { + automationRuleSequence += 1 + const rule = { + ...draft, + id: `fixture-scheduled-rule-${automationRuleSequence}`, + createdAt: fixtureNowMs, + updatedAt: fixtureNowMs + } + scheduledReportRules.push(rule) + return structuredClone(rule) +}) +// `updateRule` / `setRuleEnabled` 收的是**单个对象**(`{ id, draft }` / `{ id, enabled }`), +// 与 `createRule(draft)` / `deleteRule(id)` 的位置参数不同 —— preload 就是这么拼的。 +handle('automation:updateRule', (input) => { + const { id, draft } = input || {} + const index = scheduledReportRules.findIndex((rule) => rule.id === id) + if (index < 0) return null + scheduledReportRules[index] = { + ...scheduledReportRules[index], + ...(draft || {}), + id, + updatedAt: fixtureNowMs + } + return structuredClone(scheduledReportRules[index]) +}) +handle('automation:deleteRule', (id) => { + const index = scheduledReportRules.findIndex((rule) => rule.id === id) + if (index < 0) return false + scheduledReportRules.splice(index, 1) + return true +}) +handle('automation:setRuleEnabled', (input) => { + const { id, enabled } = input || {} + const index = scheduledReportRules.findIndex((rule) => rule.id === id) + if (index < 0) return null + scheduledReportRules[index] = { + ...scheduledReportRules[index], + enabled: enabled === true, + updatedAt: fixtureNowMs + } + return structuredClone(scheduledReportRules[index]) +}) + +/* + * 微信异常通知(定时日报的全局能力,不是单条规则的配置)。 + * + * 能力判定与 `ScheduledReportService#checkNotificationCapability` 一致:**Agent Hub 离线 + * 就是不可用**,且要带上那句可操作的原因。fixture 里 Hub 是 offline,所以这里如实回 + * 不可用 —— 不为了让开关点得动而假装就绪。 + * + * 这四个通道生产环境都有;不注册会被渲染层的降级兜底吞掉,页面看起来"正常", + * 但测的就不是真实文案了。 + */ +handle('automation:getScheduledReportNotificationSettings', () => ({ + enabled: scheduledReportNotificationEnabled +})) +handle('automation:getScheduledReportNotificationCapability', () => + agentHubStatus().hub === 'online' + ? { ready: true, recipient: 'wxid_fixture_self' } + : { + ready: false, + reason: 'agent_hub_offline', + error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。' + } +) +handle('automation:setScheduledReportNotificationEnabled', (enabled) => { + if (enabled && agentHubStatus().hub !== 'online') { + scheduledReportNotificationEnabled = false + return { + success: false, + data: { enabled: false }, + reason: 'agent_hub_offline', + error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。' + } + } + scheduledReportNotificationEnabled = Boolean(enabled) + return { success: true, data: { enabled: scheduledReportNotificationEnabled } } +}) +handle('automation:testScheduledReportErrorNotification', (ruleId) => + scheduledReportRules.some((rule) => rule.id === ruleId) + ? { + success: true, + data: { ruleId, notificationId: 'fixture-notification', sentAt: fixtureNowMs } + } + : { success: false, error: '未找到这条定时日报规则' } +) handle('automation:listGroups', () => automationGroupOptions()) handle('automation:listExecutions', () => []) handle('automation:clearExecutions', () => true) @@ -520,7 +616,6 @@ const scheduledReportTasks = : [] const scheduledReportExecutions = [] const generatedReports = [] -let scheduledReportNotificationEnabled = false const scheduledReportNextRun = (scheduleTime) => { const [hours, minutes] = String(scheduleTime || '09:00') .split(':') @@ -1296,6 +1391,18 @@ handle('agent-hub:clearLogs', () => ({ success: true })) handle('agent-hub:startLogin', () => ({ status: agentHubStatus() })) handle('agent-hub:cancelLogin', () => ({ status: agentHubStatus() })) handle('agent-hub:disconnect', () => ({ status: agentHubStatus() })) + +/* + * Agent Hub 对话记录(本机 conversations.json 的只读回看)。 + * + * fixture 里 Hub 是 offline、也没有任何收发记录,所以恒为空 —— 与 + * `agent-hub:getStatus` 的 offline 状态保持一致。 + * + * 通道**必须**注册:会话面板挂载即调用,缺 handler 会被渲染层记成 pageerror。 + */ +handle('agent-hub:getConversations', () => []) +handle('agent-hub:getConversation', () => null) +handle('agent-hub:clearConversations', () => ({ success: true })) handle('image:getConfig', () => ({ success: true, configured: true, diff --git a/tests/integration/image-text-index-concurrency.test.ts b/tests/integration/image-text-index-concurrency.test.ts index 77b176e..242824e 100644 --- a/tests/integration/image-text-index-concurrency.test.ts +++ b/tests/integration/image-text-index-concurrency.test.ts @@ -38,7 +38,13 @@ function makeRoot(): string { } afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) + // Windows 上 sqlite 句柄未释放时 unlink 会 EBUSY;retry 吸收瞬时占用, + // 免得 teardown 抛错把真正的断言失败盖掉。 + await Promise.all( + roots + .splice(0) + .map((root) => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })) + ) }) /** 合法 PNG 头 + 唯一尾部:不同 seed → 不同内容身份(sha256)。 */ @@ -164,8 +170,14 @@ function harness(options: { } } +/** + * 等整遍 pass 结束。 + * + * 必须显式给预算:这组用例里有 1050 张的 pass,Windows runner 比本机慢一个数量级, + * 默认 1000ms 会超时(其余 image-text-index 用例组也是显式 15s~60s)。 + */ const finishPass = async (service: ImageTextIndexService): Promise => { - await vi.waitFor(() => expect(service.isRunning()).toBe(false)) + await vi.waitFor(() => expect(service.isRunning()).toBe(false), { timeout: 60_000 }) } describe('并发契约', () => { @@ -491,7 +503,7 @@ describe('并发契约', () => { }) await restarted.startPass() - await vi.waitFor(() => expect(restarted.isRunning()).toBe(false)) + await vi.waitFor(() => expect(restarted.isRunning()).toBe(false), { timeout: 60_000 }) expect(recognize2).not.toHaveBeenCalled() restarted.resetAccount() diff --git a/tests/integration/image-text-index-store-cache.test.ts b/tests/integration/image-text-index-store-cache.test.ts index ef643eb..27c3269 100644 --- a/tests/integration/image-text-index-store-cache.test.ts +++ b/tests/integration/image-text-index-store-cache.test.ts @@ -262,6 +262,11 @@ describe('派生库句柄的账号身份缓存', () => { h.service.resetAccount() }) + /** + * 显式预算(见结尾 `30_000`):本用例**故意**烧 ≥700ms 的 sleep,再叠两次真实 SQLite pass—— + * 本机实测 ~3.9s,已吃掉 vitest 默认 5000ms 的约 80%(Windows 实测 5097ms 越线)。 + * 内外预算本就该对齐:`runPass` 内部已在等 60_000ms,外层卡默认 5s 是漏写。 + */ it('pre-loop cost is excluded from per-image cost', async () => { const PRE_LOOP_MS = 400 const withPreLoop = createHarness({ @@ -308,7 +313,7 @@ describe('派生库句柄的账号身份缓存', () => { withPreLoop.service.resetAccount() control.service.resetAccount() - }) + }, 30_000) }) /** diff --git a/tests/integration/preload-contract.test.ts b/tests/integration/preload-contract.test.ts index 7432eb3..d2f90c2 100644 --- a/tests/integration/preload-contract.test.ts +++ b/tests/integration/preload-contract.test.ts @@ -118,8 +118,11 @@ describe('preload IPC contract', () => { createTime: 1785553200, svrId: 'server-11' } + // preload 把 options 一路透传(不传即显式 undefined),主进程签名是 (reference, options?)。 await api.recognizeVoice(voiceReference) - expect(invoke).toHaveBeenLastCalledWith('voice:recognize', voiceReference) + expect(invoke).toHaveBeenLastCalledWith('voice:recognize', voiceReference, undefined) + await api.recognizeVoice(voiceReference, { force: true }) + expect(invoke).toHaveBeenLastCalledWith('voice:recognize', voiceReference, { force: true }) await api.cancelVoiceRecognition(voiceReference) expect(invoke).toHaveBeenLastCalledWith('voice:cancelRecognition', voiceReference) await api.downloadVoiceModel() diff --git a/tests/integration/system-ocr-windows.test.ts b/tests/integration/system-ocr-windows.test.ts index 74756a6..de0e806 100644 --- a/tests/integration/system-ocr-windows.test.ts +++ b/tests/integration/system-ocr-windows.test.ts @@ -38,8 +38,25 @@ const expectContainsTokens = (text: string, tokens: string[]): void => { const capability = await systemOcrService.getCapability() const onWindows = process.platform === 'win32' +const nativeAvailable = onWindows && capability.available const platformGate = onWindows ? it : it.skip -const nativeGate = onWindows && capability.available ? it : it.skip +const nativeGate = nativeAvailable ? it : it.skip + +/** + * Windows 引擎是**按语言包**建识别器的:机器上只有 en-US 时能认英文、认不出中文。 + * 所以「中文 token」只在确认存在中文识别器时才算硬门槛 —— CI 的 Windows runner + * 只装了 en-US,中文识别不能当所有 CI 的硬门槛(文件头的约定)。 + * + * `language` 为 null 表示"跟随系统语言",无法据以判定,按不可用处理(宁可跳过不可误红)。 + */ +const nativeChineseGate = nativeAvailable && /^zh/i.test(capability.language ?? '') ? it : it.skip + +if (nativeAvailable && !/^zh/i.test(capability.language ?? '')) { + console.warn( + `[integration] Windows OCR 中文用例跳过:识别器语言为「${capability.language ?? '跟随系统语言'}」,` + + '本机未安装中文 OCR 语言包。中文用例需在装有 zh-Hans-CN 识别器的 Windows 机器上通过。' + ) +} describe('Windows System OCR native fidelity', () => { platformGate('reports a usable capability on this machine', () => { @@ -49,7 +66,7 @@ describe('Windows System OCR native fidelity', () => { } }) - nativeGate('recognizes simplified Chinese text', async () => { + nativeChineseGate('recognizes simplified Chinese text', async () => { const result = await systemOcrService.recognize({ imageDataUrl: toDataUrl('system-ocr-zh.png', 'image/png') }) @@ -72,7 +89,7 @@ describe('Windows System OCR native fidelity', () => { expectContainsTokens(result.text, ['TraceMemo', 'System', 'OCR']) }) - nativeGate('recognizes mixed Chinese/English text', async () => { + nativeChineseGate('recognizes mixed Chinese/English text', async () => { const result = await systemOcrService.recognize({ imageDataUrl: toDataUrl('system-ocr-mixed.png', 'image/png') }) diff --git a/tests/unit/agent-hub-conversation-store.test.ts b/tests/unit/agent-hub-conversation-store.test.ts index 1d9cfc4..0ee3d9b 100644 --- a/tests/unit/agent-hub-conversation-store.test.ts +++ b/tests/unit/agent-hub-conversation-store.test.ts @@ -55,7 +55,10 @@ describe('AgentHubConversationStore', () => { const conversation = store.get('user-a') expect(conversation?.messages.map((item) => item.direction)).toEqual(['in', 'out']) - expect(statSync(filePath).mode & 0o777).toBe(0o600) + // Windows 没有 POSIX 权限位,chmod 0600 是空操作 ⇒ 只在 POSIX 上断言。 + if (process.platform !== 'win32') { + expect(statSync(filePath).mode & 0o777).toBe(0o600) + } }) it('保留发送失败的记录与错误码,便于在界面上对账', () => { diff --git a/tests/unit/automation-service-group-exit.test.ts b/tests/unit/automation-service-group-exit.test.ts index d1a2f1a..e9ad6e5 100644 --- a/tests/unit/automation-service-group-exit.test.ts +++ b/tests/unit/automation-service-group-exit.test.ts @@ -47,6 +47,16 @@ const readyCapability: PersonalWechatSendCapability = { const NOW = 1_700_000_000_000 +/** 本地时区的 `YYYY-MM-DD HH:mm:ss`:`{time}` 按本地时区渲染,写死偏移量只在东八区通过。 */ +function localStamp(epochMs: number): string { + const date = new Date(epochMs) + const pad = (value: number): string => String(value).padStart(2, '0') + return ( + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` + + `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` + ) +} + function exitEvent(overrides: Partial = {}): GroupMemberExitedEvent { return { eventId: 'room@chatroom:wxid_member:1700000000000:1', @@ -338,7 +348,10 @@ describe('AutomationService.handleGroupExit', () => { it('发送能力缺失 → execution failed(退群事实不受影响)', async () => { const harness = createHarness() - harness.setCapability({ ...readyCapability, capabilities: { text: false, image: false, voice: false } }) + harness.setCapability({ + ...readyCapability, + capabilities: { text: false, image: false, voice: false } + }) await harness.service.handleGroupExit(exitEvent()) @@ -395,7 +408,7 @@ describe('AutomationService.handleGroupExit', () => { if (content.type === 'text') { // 群名({groupName})与成员群昵称({groupRemark})是两个值,不能混。 expect(content.text).toBe( - '产品测试群|微信昵称|群内备注|wxid_member|243->242|2023-11-15 06:13:20' + `产品测试群|微信昵称|群内备注|wxid_member|243->242|${localStamp(NOW)}` ) } }) diff --git a/tests/unit/runtime-packaging.test.ts b/tests/unit/runtime-packaging.test.ts index 8e42f22..58e4715 100644 --- a/tests/unit/runtime-packaging.test.ts +++ b/tests/unit/runtime-packaging.test.ts @@ -60,7 +60,10 @@ const { pruneIntelMacKeyTool, pruneForeignArchConnectors, pruneForeignArchNativeRuntimes, - validateRuntimeBinaryArchitecture + validateRuntimeBinaryArchitecture, + findSendRuntime, + enforceSendRuntimeBoundary, + default: afterPack } = nodeRequire('../../scripts/after-pack.cjs') as { pruneIntelMacKeyTool: ( runtimeResources: string, @@ -83,6 +86,20 @@ const { arch: string, label: string ) => void + findSendRuntime: (runtimeResources: string) => string | null + enforceSendRuntimeBoundary: ( + runtimeResources: string, + platform: NodeJS.Platform, + bundlesSendRuntime?: boolean + ) => string | null + default: (context: AfterPackContext) => Promise +} + +type AfterPackContext = { + appOutDir: string + electronPlatformName: NodeJS.Platform + arch?: number + packager: { appInfo: { productFilename: string } } } const { readBinaryArchitectures } = nodeRequire('../../scripts/binary-arch.cjs') as { readBinaryArchitectures: (filePath: string) => string[] @@ -435,6 +452,121 @@ describe('per-architecture macOS packaging', () => { }) }) +describe('send runtime packaging boundary', () => { + const boundaryRoot = mkdtempSync(join(tmpdir(), 'wxe-send-runtime-')) + afterAll(() => rmSync(boundaryRoot, { recursive: true, force: true })) + + function resourcesWithSendRuntime(name: string, unpacked = false): string { + const resources = join(boundaryRoot, name, 'Contents', 'Resources') + const runtime = unpacked + ? join(resources, 'app.asar.unpacked', 'resources', 'runtime', 'darwin-arm64') + : join(resources, 'resources', 'runtime', 'darwin-arm64') + mkdirSync(runtime, { recursive: true }) + writeFileSync(join(runtime, 'tm-wechat-host'), 'fixture') + return resources + } + + it('accepts a macOS bundle without the send runtime', () => { + const resources = join(boundaryRoot, 'clean', 'Contents', 'Resources') + mkdirSync(resources, { recursive: true }) + + expect(findSendRuntime(resources)).toBeNull() + expect(enforceSendRuntimeBoundary(resources, 'darwin', false)).toBeNull() + }) + + it('fails a bundle that still carries the send runtime', () => { + expect(() => + enforceSendRuntimeBoundary(resourcesWithSendRuntime('dirty'), 'darwin', false) + ).toThrow(/must not include the WeChat send runtime/) + }) + + it('detects the send runtime when it lands under app.asar.unpacked', () => { + const resources = resourcesWithSendRuntime('unpacked', true) + + expect(findSendRuntime(resources)).toBe( + join(resources, 'app.asar.unpacked', 'resources', 'runtime', 'darwin-arm64') + ) + expect(() => enforceSendRuntimeBoundary(resources, 'darwin', false)).toThrow( + /must not include the WeChat send runtime/ + ) + }) + + it('requires the send runtime when the build is configured to bundle it', () => { + const missing = join(boundaryRoot, 'runtime-missing', 'Contents', 'Resources') + mkdirSync(missing, { recursive: true }) + + expect(() => enforceSendRuntimeBoundary(missing, 'darwin', true)).toThrow( + /requires the WeChat send runtime/ + ) + + const present = resourcesWithSendRuntime('runtime-ok') + expect(enforceSendRuntimeBoundary(present, 'darwin', true)).toBe( + join(present, 'resources', 'runtime', 'darwin-arm64') + ) + }) + + it('ignores non-macOS bundles', () => { + const resources = resourcesWithSendRuntime('win32') + + expect(enforceSendRuntimeBoundary(resources, 'win32', false)).toBeNull() + expect(enforceSendRuntimeBoundary(resources, 'win32', true)).toBeNull() + }) + + it('keeps the send runtime build off the release publishing path', () => { + const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8')) as { + scripts: Record + } + + expect(Object.keys(pkg.scripts).filter((name) => name.includes('send-runtime'))).toEqual([ + 'build:mac:arm64:send-runtime' + ]) + expect(pkg.scripts['build:mac:arm64:send-runtime']).toContain('TM_SEND_RUNTIME_BUILD=1') + expect(pkg.scripts['build:mac:arm64:send-runtime']).toContain( + 'electron-builder.send-runtime.yml' + ) + expect(pkg.scripts['build:mac:arm64:send-runtime']).not.toContain('--publish') + + for (const [name, command] of Object.entries(pkg.scripts)) { + if (!name.startsWith('release')) continue + expect(command).not.toContain('electron-builder.send-runtime.yml') + expect(command).not.toContain('TM_SEND_RUNTIME_BUILD') + } + }) + + it('excludes the send runtime in the default builder config', () => { + const config = readFileSync(resolve(__dirname, '../../electron-builder.yml'), 'utf8') + + expect(config).toContain("'!runtime/darwin-arm64/**'") + }) + + // 只测函数是测不出「边界校验有没有被接上去」的。这个 bundle 故意不含 app.asar 与 + // Reader Skill:一旦 afterPack 把边界校验排到其它校验之后,抛出的就会是 + // "Missing packaged application archive" 而不是边界错误,用例即失败。 + it('applies the boundary check inside afterPack ahead of every other validation', async () => { + const appOutDir = join(boundaryRoot, 'wired') + const runtime = join( + appOutDir, + 'TraceMemo.app', + 'Contents', + 'Resources', + 'resources', + 'runtime', + 'darwin-arm64' + ) + mkdirSync(runtime, { recursive: true }) + writeFileSync(join(runtime, 'tm-wechat-host'), 'fixture') + + await expect( + afterPack({ + appOutDir, + electronPlatformName: 'darwin', + arch: 3, + packager: { appInfo: { productFilename: 'TraceMemo' } } + }) + ).rejects.toThrow(/must not include the WeChat send runtime/) + }) +}) + describe('release publishing policy', () => { it('uploads GitHub releases as drafts until the notes are reviewed', () => { for (const configFile of ['electron-builder.yml', 'electron-builder.win.yml']) { diff --git a/tests/unit/scheduled-report-service.test.ts b/tests/unit/scheduled-report-service.test.ts index afdbe6b..69d3e42 100644 --- a/tests/unit/scheduled-report-service.test.ts +++ b/tests/unit/scheduled-report-service.test.ts @@ -36,15 +36,15 @@ const onlineAgentHubStatus = (): AgentHubStatus => ({ }) const SCHEDULE_TIME = '18:30' + +/** 按本地时区构造 ISO 串:调度走 `Date#setHours`,写死偏移量会让用例只在东八区通过。 */ +const localISO = (year: number, month: number, day: number, hour: number, minute: number): string => + new Date(year, month - 1, day, hour, minute, 0, 0).toISOString() + /** 一个"当天 18:30 之后"的时刻,保证当日槽位已到点。 */ -const AFTER_SLOT = new Date('2026-08-27T18:31:00+08:00') -/** - * 规则的创建时间:**固定**在 2026-08-20。 - * - * 必须早于所有参与断言的槽位,否则「槽位晚于创建时间」这道闸会把一切挡住; - * 也绝不能用 `Date.now()` —— 那样断言会随墙上时钟漂移。 - */ -const CREATED_AT = Date.parse('2026-08-20T00:00:00+08:00') +const AFTER_SLOT = new Date(2026, 7, 27, 18, 31) +/** 必须早于所有参与断言的槽位,且固定,不能用 `Date.now()`。 */ +const CREATED_AT = new Date(2026, 7, 20, 0, 0).getTime() function makeRule(overrides: Partial = {}): AutomationRule { return { @@ -114,24 +114,18 @@ describe('定时日报 · 时间计算', () => { expect(validateScheduleTime('09:05')).toBe(true) expect(validateScheduleTime('24:00')).toBe(false) expect(validateScheduleTime('9:5')).toBe(false) - const from = new Date('2026-08-27T10:00:00+08:00') - expect(calculateNextRunAt('18:30', from)).toBe( - new Date('2026-08-27T18:30:00+08:00').toISOString() - ) + const from = new Date(2026, 7, 27, 10, 0) + expect(calculateNextRunAt('18:30', from)).toBe(localISO(2026, 8, 27, 18, 30)) // 已经过了今天的点 ⇒ 顺延到明天。 - expect(calculateNextRunAt('09:00', from)).toBe( - new Date('2026-08-28T09:00:00+08:00').toISOString() - ) + expect(calculateNextRunAt('09:00', from)).toBe(localISO(2026, 8, 28, 9, 0)) }) it('resolveDueScheduledSlot 给出"已经到点、最近的那个"槽位', () => { // 18:31 时,18:30 已经到点 ⇒ 槽位是今天 18:30。 - expect(resolveDueScheduledSlot(SCHEDULE_TIME, AFTER_SLOT)).toBe( - new Date('2026-08-27T18:30:00+08:00').toISOString() - ) + expect(resolveDueScheduledSlot(SCHEDULE_TIME, AFTER_SLOT)).toBe(localISO(2026, 8, 27, 18, 30)) // 18:29 时,今天的点还没到 ⇒ 最近一个槽位是昨天。 - expect(resolveDueScheduledSlot(SCHEDULE_TIME, new Date('2026-08-27T18:29:00+08:00'))).toBe( - new Date('2026-08-26T18:30:00+08:00').toISOString() + expect(resolveDueScheduledSlot(SCHEDULE_TIME, new Date(2026, 7, 27, 18, 29))).toBe( + localISO(2026, 8, 26, 18, 30) ) }) }) @@ -148,7 +142,7 @@ describe('定时日报 · 调度器', () => { expect(ruleId).toBe('rule-1') expect(options).toEqual({ trigger: 'schedule', - scheduledSlot: new Date('2026-08-27T18:30:00+08:00').toISOString() + scheduledSlot: localISO(2026, 8, 27, 18, 30) }) }) @@ -159,7 +153,7 @@ describe('定时日报 · 调度器', () => { schedule: { time: SCHEDULE_TIME }, report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] } }), - lastScheduledSlot: new Date('2026-08-27T18:30:00+08:00').toISOString() + lastScheduledSlot: localISO(2026, 8, 27, 18, 30) } }) const { service, executeRule } = await makeHarness({}, [rule]) @@ -209,12 +203,12 @@ describe('定时日报 · 调度器', () => { schedule: { time: SCHEDULE_TIME }, report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] } }), - lastScheduledSlot: new Date('2026-08-26T18:30:00+08:00').toISOString() + lastScheduledSlot: localISO(2026, 8, 26, 18, 30) } }) const { service, executeRule } = await makeHarness({}, [rule]) - await service.tick(new Date('2026-08-27T18:29:00+08:00')) + await service.tick(new Date(2026, 7, 27, 18, 29)) await service.settle() expect(executeRule).not.toHaveBeenCalled() @@ -224,10 +218,10 @@ describe('定时日报 · 调度器', () => { // 今天 12:00 建的规则、执行时间 18:30。今天 18:30 还没到, // 最近的槽位是**昨天 18:30** —— 它早于 createdAt,不属于这条规则。 // 少了这道闸,新建规则会在下一次 15 秒 tick 里立刻发一份昨天的报告。 - const rule = makeRule({ createdAt: Date.parse('2026-08-27T12:00:00+08:00') }) + const rule = makeRule({ createdAt: new Date(2026, 7, 27, 12, 0).getTime() }) const { service, executeRule } = await makeHarness({}, [rule]) - await service.tick(new Date('2026-08-27T18:29:00+08:00')) + await service.tick(new Date(2026, 7, 27, 18, 29)) await service.settle() expect(executeRule).not.toHaveBeenCalled() @@ -242,18 +236,18 @@ describe('定时日报 · 调度器', () => { schedule: { time: SCHEDULE_TIME }, report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] } }), - lastScheduledSlot: new Date('2026-08-25T18:30:00+08:00').toISOString() + lastScheduledSlot: localISO(2026, 8, 25, 18, 30) } }) const { service, executeRule } = await makeHarness({}, [rule]) - await service.tick(new Date('2026-08-27T18:29:00+08:00')) + await service.tick(new Date(2026, 7, 27, 18, 29)) await service.settle() expect(executeRule).toHaveBeenCalledTimes(1) expect(executeRule.mock.calls[0][1]).toEqual({ trigger: 'schedule', - scheduledSlot: new Date('2026-08-26T18:30:00+08:00').toISOString() + scheduledSlot: localISO(2026, 8, 26, 18, 30) }) }) @@ -264,7 +258,7 @@ describe('定时日报 · 调度器', () => { schedule: { time: SCHEDULE_TIME }, report: { sourceConversationId: 'g1@chatroom', messageTypes: ['text'] } }), - lastScheduledSlot: new Date('2026-08-27T18:30:00+08:00').toISOString() + lastScheduledSlot: localISO(2026, 8, 27, 18, 30) } }) const { service, executeRule } = await makeHarness({}, [rule]) @@ -272,7 +266,7 @@ describe('定时日报 · 调度器', () => { // 18:31 时最近槽位正好等于游标 ⇒ 不触发;再把时间回拨到 18:29, // 槽位退化成 08-26 18:30,仍然**比游标旧** ⇒ 同样不触发。 await service.tick(AFTER_SLOT) - await service.tick(new Date('2026-08-27T18:29:00+08:00')) + await service.tick(new Date(2026, 7, 27, 18, 29)) await service.settle() expect(executeRule).not.toHaveBeenCalled() diff --git a/tests/unit/security-and-errors.test.ts b/tests/unit/security-and-errors.test.ts index b741ad6..61e20b5 100644 --- a/tests/unit/security-and-errors.test.ts +++ b/tests/unit/security-and-errors.test.ts @@ -53,7 +53,7 @@ describe('sticker HTTP failures', () => { }) describe('personal WeChat runtime security invariants', () => { - it('waits for the macOS native runtime to detach during application shutdown', () => { + it('keeps the macOS native runtime alive across application shutdown', () => { const mainSource = readFileSync(resolve('src/main/index.ts'), 'utf8') const shutdownStart = mainSource.indexOf("app.on('before-quit'") const shutdownEnd = mainSource.indexOf('function showMainWindow', shutdownStart) @@ -62,7 +62,10 @@ describe('personal WeChat runtime security invariants', () => { expect(shutdownStart).toBeGreaterThanOrEqual(0) expect(shutdownEnd).toBeGreaterThan(shutdownStart) expect(shutdownSource).toContain('await Promise.all([') - expect(shutdownSource).toContain('macWechatRuntimeManager.shutdown()') + // host 要活过 TraceMemo(保留发送能力进程,保住已绑定的 frida 会话), + // 所以退出流程里**不得**关掉它 —— 否则每次重启都要重新绑定微信。 + // 它有自己的生命周期:微信消失后自判 stale 并自退,也可从设置卡片手动重载。 + expect(shutdownSource).not.toContain('macWechatRuntimeManager.shutdown()') }) it('does not install Python packages while preparing the native runtime', () => { diff --git a/tests/unit/wechat-ilink-account-store.test.ts b/tests/unit/wechat-ilink-account-store.test.ts index 8e95c74..85d1ad3 100644 --- a/tests/unit/wechat-ilink-account-store.test.ts +++ b/tests/unit/wechat-ilink-account-store.test.ts @@ -58,7 +58,10 @@ describe('wechat-ilink account store', () => { expect(path).toBe( join(home, '.tracememo', 'wechat-connector', 'accounts', 'bot_abc-im-bot.json') ) - expect(statSync(path).mode & 0o777).toBe(0o600) + // Windows 没有 POSIX 权限位,chmod 0600 是空操作 ⇒ 只在 POSIX 上断言。 + if (process.platform !== 'win32') { + expect(statSync(path).mode & 0o777).toBe(0o600) + } expect(loadAllCredentials(() => home)).toEqual([credentials]) }) @@ -150,7 +153,10 @@ describe('wechat-ilink account store', () => { accountsDirectory(() => home), `${accountId}.context.json` ) - expect(statSync(path).mode & 0o777).toBe(0o600) + // Windows 没有 POSIX 权限位,chmod 0600 是空操作 ⇒ 只在 POSIX 上断言。 + if (process.platform !== 'win32') { + expect(statSync(path).mode & 0o777).toBe(0o600) + } const stored = JSON.parse(readFileSync(path, 'utf8')) as { tokens: Record diff --git a/tests/unit/wechat-inbound-inbox.test.ts b/tests/unit/wechat-inbound-inbox.test.ts index f2d9870..b481420 100644 --- a/tests/unit/wechat-inbound-inbox.test.ts +++ b/tests/unit/wechat-inbound-inbox.test.ts @@ -45,7 +45,10 @@ describe('WechatInboundInbox', () => { expect(accepted.map((entry) => entry.messageId)).toEqual(['1', '2']) expect(accepted[0].contextToken).toBe('ctx-1') - expect(statSync(filePath).mode & 0o777).toBe(0o600) + // Windows 没有 POSIX 权限位,chmod 0600 是空操作 ⇒ 只在 POSIX 上断言。 + if (process.platform !== 'win32') { + expect(statSync(filePath).mode & 0o777).toBe(0o600) + } expect(inbox.size()).toBe(2) }) @@ -111,6 +114,9 @@ describe('WechatInboundInbox', () => { const raw = readFileSync(filePath, 'utf8') expect(raw).toContain('机密内容') - expect(statSync(filePath).mode & 0o777).toBe(0o600) + // Windows 没有 POSIX 权限位,chmod 0600 是空操作 ⇒ 只在 POSIX 上断言。 + if (process.platform !== 'win32') { + expect(statSync(filePath).mode & 0o777).toBe(0o600) + } }) })