mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:01:58 +00:00
W3-d 检查脚本:check-share 新建,check-capabilities 加邮件
- check-share.mjs:/s 分享页不进外壳(整页只请求分享页一个接口)、key 只在请求头里、视图照画而流程组件是说明、印章常显、 页脚、点数字说明出处在报告里、下载报告带请求头、撤销 / 到期 / 链接不完整写明、没允许嵌入的在 iframe 里不显示;应用编辑里 开关没开时不给新建、建分享链接只显示一次、列表与撤销。接口全部伪造,按路径开头拦(前端模块地址里也有「/api/」) - check-capabilities:「邮件服务」一节(管理员改、搭建者只看、口令只说设没设、有改动时不能发测试邮件、测试邮件照服务端 的话写「已交给邮件服务器」)、待办汇总的发送时间只交这一项、邮件服务没启用时不列邮件格式、个人邮件渠道写「发到账号邮箱」 且建的时候不带地址、投递记录写「已交给邮件服务器」;可订阅事件的期望跟着第 3 波打开的待办汇总、密钥快到期更新 - check-all 登记 share
This commit is contained in:
@@ -99,6 +99,7 @@ const ORDER = [
|
||||
'app-editor', // 应用编辑:固定版本、派生、页面树和属性面板、检查定位、发布时选处置、助手流
|
||||
'capabilities', // 能力登记、端点、密钥掩码;设置页的身份、通知、审计
|
||||
'access-keys', // 设置页的访问密钥:服务账号、密钥只显示一次、撤销、轮换、最近的请求、接口说明
|
||||
'share', // 分享链接:/s 分享页不进外壳、印章常显、页脚、下载报告、撤销后的说明、嵌入;应用编辑里的分享管理
|
||||
// 普通用户建应用(docs/design/normal-user-apps.md §4.8):全是伪造接口、不连真数据
|
||||
'blueprint', // 新建应用的起点、蓝图卡片、反问、试运行、发布摘要
|
||||
'personal', // 个人应用:建、刷新、提交审核
|
||||
@@ -115,7 +116,7 @@ const ESTIMATE = {
|
||||
// 占位,换成真检查时按实测改
|
||||
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33, 'report-export': 30,
|
||||
// 应用平台:还没实测,按同类页面估(全是伪造接口、不连真数据)
|
||||
apps: 60, inbox: 45, 'app-editor': 45, capabilities: 30, 'access-keys': 30,
|
||||
apps: 60, inbox: 45, 'app-editor': 45, capabilities: 30, 'access-keys': 30, share: 30,
|
||||
// 普通用户建应用:还没实测,按同类页面估
|
||||
blueprint: 90, personal: 45, records: 30, files: 30,
|
||||
}
|
||||
|
||||
+127
-12
@@ -6,6 +6,9 @@
|
||||
// 管理员能开;测试连接按环境发,照服务端的话写(2xx 才是成功,能连上但 404 是提醒);
|
||||
// - 关登录(默认)时所有人等同管理员:治理、退役、身份设置都能改;
|
||||
// - 设置页的身份、通知、审计三个页签在,通知渠道的地址只显示主机名;
|
||||
// - 邮件(04 细案 B2):「邮件服务」一节管理员改、搭建者只看,口令只说设没设、留空不改,有没保存的改动时不能发测试邮件;
|
||||
// 每天的待办汇总的发送时间;邮件服务没启用时不列「邮件」格式;个人邮件渠道不填地址、写「发到账号邮箱 …」,没有邮箱照实
|
||||
// 写发不出去;投递记录里邮件成功写「已交给邮件服务器」,不写已送达;
|
||||
// - 能力字段的待审阅(D-i)复用数据目录的审阅队列:成批确认逐项带能力的治理版本;
|
||||
// - 自定义工具可以绑定登记的端点(只填路径);MCP 的认证头按掩码编辑;本地子进程只有管理员能建、能改(A6);
|
||||
// - 单点登录映射管理员:用户或组至少一项;开了登录的使用者在设置页只剩个人的主题和通知,换主题不写服务端(D-s)。
|
||||
@@ -86,7 +89,11 @@ const MCP_ROWS = [
|
||||
headers: { Authorization: 'Bearer ****9a2c' }, endpoint_id: null, status: 'ok', last_error: null, tools_cache: [] },
|
||||
]
|
||||
|
||||
async function open(path, { conflict = false, me = OFF } = {}) {
|
||||
const NOTIFY_ITEMS = [
|
||||
{ id: 'ch1', name: '财务群', scope: 'global', owner_id: null, format: 'feishu', url_host: 'open.feishu.cn', signed: true,
|
||||
events: ['task.opened'], enabled: true }]
|
||||
|
||||
async function open(path, { conflict = false, me = OFF, settings = null, notify = null, deliveries = [] } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
opened.add(ctx)
|
||||
const page = await ctx.newPage()
|
||||
@@ -95,7 +102,8 @@ async function open(path, { conflict = false, me = OFF } = {}) {
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
const state = { caps: JSON.parse(JSON.stringify(CAPS)), endpoints: JSON.parse(JSON.stringify(ENDPOINTS)), governs: [], endpointWrites: [],
|
||||
tests: [], syncs: 0, settings: [], reviews: [], batches: [], tools: [], mcp: [], prefs: [] }
|
||||
tests: [], syncs: 0, settings: [], reviews: [], batches: [], tools: [], mcp: [], prefs: [], channelWrites: [], mailTests: 0,
|
||||
values: settings ? JSON.parse(JSON.stringify(settings)) : { ui: { theme: 'dark' } } }
|
||||
|
||||
await page.route(/\/api\//, (route) => {
|
||||
const req = route.request()
|
||||
@@ -133,8 +141,25 @@ async function open(path, { conflict = false, me = OFF } = {}) {
|
||||
return json(route, { id: 'm9', ...req.postDataJSON() })
|
||||
})
|
||||
await page.route(/\/api\/settings$/, (route) => {
|
||||
if (route.request().method() !== 'GET') { state.prefs.push(route.request().postDataJSON()); return json(route, {}) }
|
||||
return me.mode !== 'off' && me.role === 'user' ? json(route, { detail: '没有权限' }, 403) : json(route, { ui: { theme: 'dark' } })
|
||||
if (route.request().method() !== 'GET') {
|
||||
const body = route.request().postDataJSON()
|
||||
state.prefs.push(body)
|
||||
// 同服务端:给了的组并进去(mail 的口令不回、只说设没设),交回整份
|
||||
for (const [k, v] of Object.entries(body?.values ?? {})) {
|
||||
state.values[k] = { ...(state.values[k] ?? {}), ...v }
|
||||
if (k === 'mail') {
|
||||
const pw = v.password
|
||||
state.values.mail = { ...state.values.mail, password: null,
|
||||
password_set: typeof pw === 'string' ? pw !== '' : !!state.values.mail.password_set }
|
||||
}
|
||||
}
|
||||
return json(route, state.values)
|
||||
}
|
||||
return me.mode !== 'off' && me.role === 'user' ? json(route, { detail: '没有权限' }, 403) : json(route, state.values)
|
||||
})
|
||||
await page.route(/\/api\/settings\/mail\/test$/, (route) => {
|
||||
state.mailTests += 1
|
||||
return json(route, { ok: true, message: '已交给邮件服务器(发到 root@example.com),是否送达以邮件服务器为准,请到收件箱确认' })
|
||||
})
|
||||
await page.route(/\/api\/capabilities\/sync$/, (route) => { state.syncs += 1; return json(route, { changed: 2 }) })
|
||||
await page.route(/\/api\/capabilities\/[^/]+\/governance$/, (route) => {
|
||||
@@ -177,10 +202,16 @@ async function open(path, { conflict = false, me = OFF } = {}) {
|
||||
await page.route(/\/api\/audit(\?.*)?$/, (route) => json(route, { items: [
|
||||
{ id: 'a1', at: '2026-10-10T02:00:00Z', actor: '张工', verified: false, action: 'capability.govern', subject_kind: 'capability',
|
||||
subject_id: 'erp_pay', data: { version: 2 } }] }))
|
||||
await page.route(/\/api\/notify\/channels$/, (route) => json(route, { items: [
|
||||
{ id: 'ch1', name: '财务群', scope: 'global', owner_id: null, format: 'feishu', url_host: 'open.feishu.cn', signed: true,
|
||||
events: ['task.opened'], enabled: true }] }))
|
||||
await page.route(/\/api\/notify\/deliveries(\?.*)?$/, (route) => json(route, { items: [] }))
|
||||
await page.route(/\/api\/notify\/channels$/, (route) => {
|
||||
const req = route.request()
|
||||
if (req.method() === 'POST') {
|
||||
const body = req.postDataJSON()
|
||||
state.channelWrites.push(body)
|
||||
return json(route, { id: 'ch-new', owner_id: null, url_host: '', signed: false, enabled: true, ...body }, 201)
|
||||
}
|
||||
return json(route, { items: notify?.items ?? NOTIFY_ITEMS, ...(notify?.mail ? { mail: notify.mail } : {}) })
|
||||
})
|
||||
await page.route(/\/api\/notify\/deliveries(\?.*)?$/, (route) => json(route, { items: deliveries }))
|
||||
|
||||
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(300)
|
||||
@@ -311,19 +342,103 @@ await section('设置 → 通知:可订阅的事件照服务端登记列(总
|
||||
return waitFor(page, async () => await dlg.locator(`[data-notify-events="${value}"]`).count() === 1)
|
||||
}
|
||||
const every = ['task.opened', 'run.finished', 'run.failed', 'action.unknown']
|
||||
check('全局渠道:推给全局渠道的五项', await waitFor(page, async () => await dlg.locator('[data-notify-events="global"]').count() === 1)
|
||||
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done']), JSON.stringify(await listed()))
|
||||
// 第 3 波起:访问密钥快到期推给全局渠道(服务账号的密钥)和个人渠道(个人密钥),每天的待办汇总只推个人渠道
|
||||
check('全局渠道:推给全局渠道的几项', await waitFor(page, async () => await dlg.locator('[data-notify-events="global"]').count() === 1)
|
||||
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'key.expiring']), JSON.stringify(await listed()))
|
||||
const text = await dlg.innerText()
|
||||
check('从来推不到的不列', !['待办升级', '待办超时', '待办处理完', '动作失败', '动作已生效'].some((t) => text.includes(t)), text)
|
||||
check('登记了还没推送的不列', !['评测有退步', '评测失败', '每天的待办汇总', '访问密钥快到期', '数据源有变化要处理', '口径升版,答案的数变了']
|
||||
check('登记了还没推送的不列', !['评测有退步', '评测失败', '数据源有变化要处理', '口径升版,答案的数变了']
|
||||
.some((t) => text.includes(t)), text)
|
||||
check('只推个人渠道的「每天的待办汇总」全局渠道不列', !text.includes('每天的待办汇总'), text)
|
||||
check('应用的渠道:不列只发给提交人的「我提交审核的应用有结果」', await scope('app')
|
||||
&& JSON.stringify(await listed()) === JSON.stringify(every), JSON.stringify(await listed()))
|
||||
check('个人渠道:五项都列', await scope('user') && JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done']),
|
||||
check('个人渠道:连同待办汇总、密钥快到期都列', await scope('user')
|
||||
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'task.digest', 'key.expiring']),
|
||||
JSON.stringify(await listed()))
|
||||
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||||
})
|
||||
|
||||
const MAIL = { enabled: true, host: 'smtp.example.com', port: 587, security: 'starttls', username: 'agentlab', password: null,
|
||||
password_set: true, from_addr: 'agentlab@example.com', from_name: 'AgentLab', default_for_assignees: false }
|
||||
|
||||
await section('设置 → 通知:邮件服务一节、每天的待办汇总', async () => {
|
||||
const { page, state, errors } = await open('/settings/notify', { settings: { mail: MAIL, notify: { public_base_url: null, digest_at: '09:00' } },
|
||||
notify: { items: NOTIFY_ITEMS, mail: { enabled: true, account_email: null } } })
|
||||
const box = page.locator('[data-mail-section="edit"]')
|
||||
check('管理员看得到、改得了邮件服务', await waitFor(page, async () => await box.count() === 1)
|
||||
&& await box.locator('[data-mail-field="host"]').inputValue() === 'smtp.example.com')
|
||||
check('口令只说设没设,输入框是空的', await box.locator('[data-mail-field="password"]').inputValue() === ''
|
||||
&& (await box.innerText()).includes('已设置;留空表示不修改'))
|
||||
check('没改动时能发测试邮件', !(await box.locator('[data-mail-test]').isDisabled()))
|
||||
await box.locator('[data-mail-field="host"]').fill('mail.example.org')
|
||||
check('有没保存的改动时先保存再发', await box.locator('[data-mail-test]').isDisabled() && (await box.innerText()).includes('先保存再发测试邮件'))
|
||||
await box.locator('[data-mail-save]').click()
|
||||
check('保存交的是 mail 一组,口令没填就不带', await waitFor(page, async () => state.prefs.length === 1)
|
||||
&& state.prefs[0].values.mail.host === 'mail.example.org' && !('password' in state.prefs[0].values.mail),
|
||||
JSON.stringify(state.prefs[0]))
|
||||
await box.locator('[data-mail-test]').click()
|
||||
check('测试邮件照服务端的话写「已交给邮件服务器」', await waitFor(page, async () => state.mailTests === 1)
|
||||
&& await waitFor(page, async () => (await page.locator('body').innerText()).includes('已交给邮件服务器(发到')))
|
||||
check('写明退信不追踪', (await box.locator('[data-mail-handoff]').innerText()).includes('退信不追踪'))
|
||||
const digest = page.locator('[data-digest-section="edit"]')
|
||||
check('待办汇总的发送时间', await digest.locator('[data-digest-at]').inputValue() === '09:00')
|
||||
await digest.locator('[data-digest-at]').fill('17:30')
|
||||
await digest.locator('[data-digest-save]').click()
|
||||
check('只交汇总时间这一项', await waitFor(page, async () => state.prefs.length === 2)
|
||||
&& JSON.stringify(state.prefs[1]) === JSON.stringify({ values: { notify: { digest_at: '17:30' } } }), JSON.stringify(state.prefs[1]))
|
||||
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||||
|
||||
const builder = { mode: 'local', username: 'bea', display_name: '搭建者', role: 'builder', groups: [], verified: true }
|
||||
const ro = await open('/settings/notify', { me: builder, settings: { mail: MAIL, notify: { public_base_url: null, digest_at: '09:00' } } })
|
||||
const roBox = ro.page.locator('[data-mail-section="readonly"]')
|
||||
check('搭建者只看:输入框都不能改、没有保存和测试', await waitFor(ro.page, async () => await roBox.count() === 1)
|
||||
&& await roBox.locator('[data-mail-field="host"]').isDisabled() && await roBox.locator('[data-mail-save]').count() === 0
|
||||
&& await roBox.locator('[data-mail-test]').count() === 0 && (await roBox.innerText()).includes('只有管理员能修改邮件服务'))
|
||||
})
|
||||
|
||||
await section('设置 → 通知:邮件格式、个人邮件渠道发到账号邮箱、投递记录', async () => {
|
||||
const off = await open('/settings/notify', { notify: { items: NOTIFY_ITEMS, mail: { enabled: false, account_email: null } } })
|
||||
await off.page.getByRole('button', { name: '添加渠道' }).first().click()
|
||||
const offDlg = off.page.locator('[role="dialog"]')
|
||||
const options = await offDlg.locator('[data-notify-format-select] option').allInnerTexts()
|
||||
check('邮件服务没启用:不列「邮件」,写明为什么', !options.includes('邮件')
|
||||
&& (await offDlg.locator('[data-notify-mail-off]').innerText()).includes('邮件服务没有启用'), options.join('、'))
|
||||
|
||||
const me = { mode: 'local', username: 'uma', display_name: '小乌', role: 'user', groups: [], verified: true, user_id: 'u-uma' }
|
||||
const items = [
|
||||
{ id: 'm1', name: '我的邮件', scope: 'user', owner_id: 'u-uma', format: 'email', url_host: '', signed: false,
|
||||
events: ['task.opened'], enabled: true, mail_to: ['uma@example.com'] },
|
||||
{ id: 'm2', name: '旧邮件', scope: 'user', owner_id: 'u-uma', format: 'email', url_host: '', signed: false,
|
||||
events: ['task.opened'], enabled: true, mail_to: [] },
|
||||
]
|
||||
const deliveries = [
|
||||
{ id: 'd1', channel_id: 'm1', event: 'task.opened', status: 'ok', http_status: null, error: null, format: 'email', created_at: '2026-10-15T02:00:00Z' },
|
||||
]
|
||||
const { page, state, errors } = await open('/settings/notify', { me, deliveries,
|
||||
notify: { items, mail: { enabled: true, account_email: 'uma@example.com' } } })
|
||||
const row = page.locator('[data-notify-channel="我的邮件"]')
|
||||
check('个人邮件渠道写「发到账号邮箱 …」', await waitFor(page, async () => await row.count() === 1)
|
||||
&& (await row.locator('[data-notify-target]').innerText()).includes('发到账号邮箱 uma@example.com'))
|
||||
check('账号没有邮箱的照实写发不出去', (await page.locator('[data-notify-channel="旧邮件"] [data-notify-target]').innerText())
|
||||
.includes('没有邮箱地址'))
|
||||
await page.getByRole('button', { name: '添加渠道' }).first().click()
|
||||
const dlg = page.locator('[role="dialog"]')
|
||||
await dlg.locator('[data-notify-format-select]').selectOption('email')
|
||||
check('个人邮件渠道不填地址,写发到账号邮箱', await dlg.locator('[data-notify-mail-to="account"]').count() === 1
|
||||
&& (await dlg.locator('[data-notify-mail-to="account"]').innerText()).includes('发到账号邮箱 uma@example.com')
|
||||
&& await dlg.locator('input[placeholder="https://"]').count() === 0)
|
||||
await dlg.getByLabel('名称').fill('待办邮件')
|
||||
await dlg.locator('[data-notify-save]').click()
|
||||
check('建的时候不带地址', await waitFor(page, async () => state.channelWrites.length === 1)
|
||||
&& state.channelWrites[0].format === 'email' && state.channelWrites[0].scope === 'user' && !('url' in state.channelWrites[0]),
|
||||
JSON.stringify(state.channelWrites[0]))
|
||||
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||||
const admin = await open('/settings/notify', { deliveries, notify: { items: NOTIFY_ITEMS, mail: { enabled: true, account_email: null } } })
|
||||
const d = admin.page.locator('[data-delivery-format="email"]')
|
||||
check('投递记录里邮件成功写「已交给邮件服务器」,不写已送达', await waitFor(admin.page, async () => await d.count() === 1)
|
||||
&& (await d.innerText()).includes('已交给邮件服务器') && !(await d.innerText()).includes('已送达'))
|
||||
})
|
||||
|
||||
await section('能力:待审阅复用数据目录的审阅队列,成批确认逐项带能力的治理版本', async () => {
|
||||
const { page, state, errors } = await open('/tools/capabilities?view=review')
|
||||
check('左栏换成待审阅视图', await page.locator('[data-capability-views="review"]').count() === 1)
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// 分享链接的检查(docs/design/next-round/04-对外集成与触达.md C2):/s 分享页和应用编辑里的分享管理。
|
||||
//
|
||||
// 守的是:
|
||||
// - /s 不进外壳:没有导航,不认人、不取目录、不连心跳——整页只发一个请求 GET /api/shares/page;
|
||||
// - 分享 key 从链接的 # 后面读,放进请求头 X-AgentLab-Share,地址里不带;
|
||||
// - 用同一个视图渲染器画:指标照常显示;待办、发起按钮这些服务端换成的说明块照画,没有待办卡、没有发起按钮;
|
||||
// - 印章常显:「数据的来历」里逐个列出视图的印章(页面上没放印章组件也列);
|
||||
// - 页脚写「只读 · 由 X 于 … 分享 · … 到期」;
|
||||
// - 点数字不开在线证据,说明出处在下载的报告里;下载报告带请求头、只取这条分享解析出来的运行;
|
||||
// - 撤销、到期、链接不完整:照服务端的话写明(「链接已撤销」……),不给空白页;
|
||||
// - 这条分享没允许嵌入、页面又在别的页面的 iframe 里:不显示内容,给「在新窗口打开」;允许嵌入的照常显示;
|
||||
// - 应用编辑 → 岗位与授权:管理员没开开关时不给新建、写明去哪开;开了之后建分享(页面、数据、有效期、嵌入),链接只显示
|
||||
// 这一次;列表写状态、打开次数;撤销先确认。
|
||||
//
|
||||
// 不写库:/api/shares/*、/api/apps/* 全部伪造。其余写请求一律 409 并记账,检查结束时必须是空的。
|
||||
//
|
||||
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273:
|
||||
// AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-share.mjs
|
||||
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
||||
|
||||
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
|
||||
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||||
const ONLY = (process.env.SHARE_ONLY ?? '').split(',').filter(Boolean)
|
||||
|
||||
let failed = 0
|
||||
const check = (name, cond, detail = '') => {
|
||||
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 160)
|
||||
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
|
||||
if (!cond) failed++
|
||||
}
|
||||
const opened = new Set()
|
||||
async function section(name, fn) {
|
||||
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
|
||||
console.log(`=== ${name} ===`)
|
||||
try {
|
||||
await fn()
|
||||
} catch (e) {
|
||||
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
|
||||
} finally {
|
||||
for (const c of opened) await c.close().catch(() => {})
|
||||
opened.clear()
|
||||
}
|
||||
}
|
||||
const waitFor = async (page, fn, ms = 5000) => {
|
||||
for (let t = 0; t < ms; t += 100) {
|
||||
if (await fn().catch(() => false)) return true
|
||||
await page.waitForTimeout(100)
|
||||
}
|
||||
return false
|
||||
}
|
||||
const clone = (v) => JSON.parse(JSON.stringify(v))
|
||||
const writes = []
|
||||
// 只拦接口:按路径的开头认(/src/api/client.ts 这类前端模块的地址里也有「/api/」,不能按正则拦)
|
||||
const API = (url) => url.pathname.startsWith('/api/')
|
||||
|
||||
// ---------------------------------------------------------------- 夹具(通用示例名)
|
||||
|
||||
const KEY = 'Sh4reKeyExampleOnly_0123456789abcdefGHIJ'
|
||||
const STAMP = {
|
||||
as_of: '2026-10-15T01:00:00Z', as_of_text: '截至 2026-10-15 09:00;统计期 week 2026-W41', pin: '周报 v3', graph_hash: 'ab12cd34',
|
||||
calibers: [{ name: '周报口径', version: 'v3' }], tier: 'formal', tier_text: '完整出具', from: '定时任务「周一出数」第 6 次', sealed: true,
|
||||
}
|
||||
const VIEW = {
|
||||
format: 'agentlab.view/1',
|
||||
app: { id: 'app-share', name: '门店周报', version: 3, level: 'published', needs_upgrade: false },
|
||||
page: { id: 'home', title: '本周概览' },
|
||||
pages: [{ id: 'home', title: '本周概览' }],
|
||||
sources: {
|
||||
wk: { state: 'ok', kind: 'latest_sealed', run_id: 'run-share-1', stamp: STAMP },
|
||||
ask: { state: 'empty', kind: 'on_demand', run_id: null, stamp: null, message: '分享链接只显示定时出数的视图:这一块的数要打开应用才看得到' },
|
||||
},
|
||||
tree: [
|
||||
{ id: 'gmv', type: 'metric', props: { label: '销售额' }, data: { state: 'ok', text: '18,218.54元', name: '销售额', unit: '元',
|
||||
evidence: { run_id: 'run-share-1', kind: 'metric', node: 'card', metric: 'gmv', eid: 'e1' } } },
|
||||
{ id: 'asked', type: 'metric', props: { label: '按条件查看' }, data: { state: 'empty',
|
||||
message: '分享链接只显示定时出数的视图:这一块的数要打开应用才看得到' } },
|
||||
{ id: 'todo', type: 'notice', props: { text: '分享链接不列待办:待办只给派给的人,请打开应用处理', tone: 'info' },
|
||||
share_placeholder: 'task_list' },
|
||||
{ id: 'go', type: 'notice', props: { text: '分享链接是只读的,不能在这里发起', tone: 'info' }, share_placeholder: 'entry_button' },
|
||||
],
|
||||
forms: {}, problems: [],
|
||||
}
|
||||
const PAYLOAD = {
|
||||
format: 'agentlab.share/1',
|
||||
share: { mode: 'latest', created_by: '编辑甲', created_at: '2026-10-14T02:00:00Z', expires_at: '2026-11-13T02:00:00Z', embed_allowed: false },
|
||||
view: VIEW,
|
||||
downloads: [{ view: 'wk', run_id: 'run-share-1', reports: ['write'], as_of_text: STAMP.as_of_text }],
|
||||
}
|
||||
|
||||
async function openShare({ hash = `#${KEY}`, payload = PAYLOAD, status = 200, error = null } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, acceptDownloads: true })
|
||||
opened.add(ctx)
|
||||
const page = await ctx.newPage()
|
||||
page.setDefaultTimeout(8000)
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
const state = { api: [], pages: [], reports: [] }
|
||||
page.on('request', (req) => { if (new URL(req.url()).pathname.startsWith('/api/')) state.api.push(new URL(req.url()).pathname) })
|
||||
const json = (route, body, code = 200) => route.fulfill({ status: code, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
await page.route(API, (route) => {
|
||||
const req = route.request()
|
||||
if (req.method() === 'GET') return json(route, { detail: '分享页不该请求这个接口' }, 418)
|
||||
writes.push(`${req.method()} ${new URL(req.url()).pathname}`)
|
||||
return json(route, { detail: '检查脚本不写库' }, 409)
|
||||
})
|
||||
await page.route(/\/api\/shares\/page(\?.*)?$/, (route) => {
|
||||
const req = route.request()
|
||||
state.pages.push({ url: req.url(), header: req.headers()['x-agentlab-share'] ?? null })
|
||||
return error ? json(route, error, status) : json(route, payload)
|
||||
})
|
||||
await page.route(/\/api\/shares\/report\.html(\?.*)?$/, (route) => {
|
||||
const req = route.request()
|
||||
state.reports.push({ url: req.url(), header: req.headers()['x-agentlab-share'] ?? null })
|
||||
return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: '<!doctype html><title>报告</title>',
|
||||
headers: { 'Content-Disposition': "attachment; filename*=UTF-8''%E5%91%A8%E6%8A%A5.html" } })
|
||||
})
|
||||
await page.goto(`${WEB}/s${hash}`, { waitUntil: 'networkidle' })
|
||||
return { ctx, page, state, errors }
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ executablePath: CHROME })
|
||||
|
||||
// ---------------------------------------------------------------- 分享页
|
||||
|
||||
await section('分享页:不进外壳,key 只放在请求头里', async () => {
|
||||
const { page, state, errors } = await openShare()
|
||||
check('页面画出来了', await waitFor(page, async () => await page.locator('[data-share-page="ok"]').count() === 1))
|
||||
check('没有外壳的导航', await page.locator('nav').count() === 0 && await page.locator('[data-nav-item]').count() === 0)
|
||||
check('只请求了分享页一个接口(不认人、不取目录、不连心跳)',
|
||||
state.api.length >= 1 && state.api.every((p) => p === '/api/shares/page'), state.api.join('、'))
|
||||
const [first] = state.pages
|
||||
check('key 在请求头 X-AgentLab-Share 里', first?.header === KEY)
|
||||
check('请求地址里不带 key', !!first && !first.url.includes(KEY))
|
||||
check('页面上没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
await section('分享页:视图照画,流程组件是说明,印章常显,页脚', async () => {
|
||||
const { page } = await openShare()
|
||||
await waitFor(page, async () => await page.locator('[data-share-page="ok"]').count() === 1)
|
||||
check('定时出数的指标照常显示', (await page.locator('[data-node="gmv"]').innerText()).includes('18,218.54'))
|
||||
check('按需视图写明只显示定时出数的', (await page.locator('[data-node="asked"]').innerText()).includes('只显示定时出数的视图'))
|
||||
check('没有待办卡、没有发起按钮', await page.locator('[data-component="task_list"], [data-entry-button], [data-task-card]').count() === 0)
|
||||
check('换成的说明块照画', (await page.locator('[data-node="todo"]').innerText()).includes('不列待办')
|
||||
&& (await page.locator('[data-node="go"]').innerText()).includes('只读'))
|
||||
check('只读的标记', (await page.locator('[data-share-readonly]').innerText()).includes('只读'))
|
||||
check('印章常显:页面上没放印章组件也列出来', await page.locator('[data-share-stamps] [data-stamp]').count() === 1
|
||||
&& (await page.locator('[data-share-stamps]').innerText()).includes('完整出具'))
|
||||
const footer = await page.locator('[data-share-footer-text]').innerText()
|
||||
check('页脚写谁分享、什么时候、什么时候到期', /^只读 · 由 编辑甲 于 .+ 分享 · .+ 到期$/.test(footer.trim()), footer)
|
||||
check('没有刷新、提交这些操作', await page.locator('[data-view-refresh], [data-form-submit]').count() === 0)
|
||||
})
|
||||
|
||||
await section('分享页:点数字说明出处在报告里,下载报告带请求头', async () => {
|
||||
const { page, state } = await openShare()
|
||||
await waitFor(page, async () => await page.locator('[data-share-page="ok"]').count() === 1)
|
||||
await page.locator('[data-node="gmv"] [data-evidence="metric"]').click()
|
||||
check('不开在线证据,说明出处在下载的报告里',
|
||||
await waitFor(page, async () => await page.locator('[data-share-evidence-note]').count() === 1)
|
||||
&& (await page.locator('[data-share-evidence-note]').innerText()).includes('下载报告'))
|
||||
check('没有去取证据接口', !state.api.some((p) => p.includes('/evidence')))
|
||||
await page.keyboard.press('Escape')
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 5000 }).catch(() => null),
|
||||
page.locator('[data-share-footer] [data-share-download="run-share-1"]').click(),
|
||||
])
|
||||
check('下载了报告文件,用服务端给的文件名', !!download && download.suggestedFilename() === '周报.html', download?.suggestedFilename())
|
||||
const [req] = state.reports
|
||||
check('下载报告带请求头、点名这次分享的运行', req?.header === KEY && req.url.includes('run=run-share-1')
|
||||
&& req.url.includes('report=write') && !req.url.includes(KEY))
|
||||
})
|
||||
|
||||
await section('分享页:撤销、到期、链接不完整都写明', async () => {
|
||||
for (const [code, title, detail] of [
|
||||
['share_revoked', '链接已撤销', '链接已撤销(2026-10-15 10:00):请向分享的人要新的链接'],
|
||||
['share_expired', '链接已到期', '链接已到期(2026-10-15 10:00):请向分享的人要新的链接'],
|
||||
]) {
|
||||
const { page } = await openShare({ status: 410, error: { detail, code } })
|
||||
const box = page.locator(`[data-share-error="${code}"]`)
|
||||
check(`${title}:标题和服务端的原话`, await waitFor(page, async () => await box.count() === 1)
|
||||
&& (await box.innerText()).includes(title) && (await box.innerText()).includes('请向分享的人要新的链接'))
|
||||
}
|
||||
const { page, state } = await openShare({ hash: '' })
|
||||
check('链接不完整(没有 # 后面那段):写明,不发请求',
|
||||
await waitFor(page, async () => await page.locator('[data-share-error="share_key_missing"]').count() === 1)
|
||||
&& state.pages.length === 0)
|
||||
})
|
||||
|
||||
await section('分享页:没允许嵌入的不在别的页面里显示,允许的照常', async () => {
|
||||
for (const allowed of [false, true]) {
|
||||
const payload = clone(PAYLOAD)
|
||||
payload.share.embed_allowed = allowed
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
|
||||
opened.add(ctx)
|
||||
const page = await ctx.newPage()
|
||||
await page.route(/\/api\/shares\/page(\?.*)?$/, (route) => route.fulfill({ status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify(payload) }))
|
||||
await page.route((url) => API(url) && !url.pathname.startsWith('/api/shares/'), (route) => route.fulfill({ status: 418, body: '' }))
|
||||
await page.setContent(`<!doctype html><iframe id="f" src="${WEB}/s#${KEY}" style="width:1000px;height:700px"></iframe>`)
|
||||
const frame = await (async () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const f = page.frames().find((x) => x.url().includes('/s'))
|
||||
if (f) return f
|
||||
await page.waitForTimeout(100)
|
||||
}
|
||||
return null
|
||||
})()
|
||||
const want = allowed ? 'ok' : 'framed'
|
||||
const ok = !!frame && await waitFor(page, async () => await frame.locator(`[data-share-page="${want}"]`).count() === 1)
|
||||
check(allowed ? '允许嵌入:iframe 里照常显示' : '没允许嵌入:iframe 里不显示内容,给在新窗口打开', ok)
|
||||
if (!allowed && frame) check('嵌着时没有画视图', await frame.locator('[data-view-renderer]').count() === 0)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 应用编辑里的分享管理
|
||||
|
||||
const SPEC = {
|
||||
format: 'agentlab.app/1', name: '门店周报', description: '', icon: 'app',
|
||||
pins: [{ alias: 'weekly', workflow_id: 'wf-share', version: 3, graph_hash: 'hash3', level: 'published', use: 'view' }],
|
||||
slots: {}, entries: [], forms: {}, task_ui: {},
|
||||
views: { wk: { source: { kind: 'latest_sealed', pin: 'weekly', from: 'formal' } } },
|
||||
pages: [{ id: 'home', title: '本周概览', body: [] }], hooks: [], notify: [],
|
||||
}
|
||||
const shareRow = (over = {}) => ({
|
||||
id: 'sh1', prefix: 'AbC123', app_id: 'app-share', page_id: 'home', page_title: '本周概览', mode: 'latest', app_version: 3,
|
||||
embed_allowed: false, created_by: 'root', created_by_name: '管理员', created_at: '2026-10-14T02:00:00Z',
|
||||
expires_at: '2026-11-13T02:00:00Z', revoked_at: null, revoked_by: null, open_count: 4, last_opened_at: '2026-10-15T02:00:00Z',
|
||||
state: 'active', ...over,
|
||||
})
|
||||
|
||||
async function openEditor({ enabled = true, items = [shareRow()] } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
opened.add(ctx)
|
||||
const page = await ctx.newPage()
|
||||
page.setDefaultTimeout(8000)
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
const json = (route, body, code = 200) => route.fulfill({ status: code, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
const state = { items: clone(items), creates: [], revokes: [] }
|
||||
const app = { id: 'app-share', name: SPEC.name, description: '', icon: 'app', status: 'active', published_version: 3,
|
||||
level: 'published', needs_upgrade: false, pending_tasks: 0, my_role: 'manage', updated_at: '2026-10-10T02:00:00Z',
|
||||
draft: clone(SPEC), draft_bindings: { slots: {} }, created_by: 'root', scope: 'shared',
|
||||
published: { version: 3, pages: [{ id: 'home', title: '本周概览' }], entries: [] } }
|
||||
await page.route(/\/api\//, (route) => {
|
||||
const req = route.request()
|
||||
if (req.method() === 'GET') return route.continue()
|
||||
writes.push(`${req.method()} ${new URL(req.url()).pathname}`)
|
||||
return json(route, { detail: '检查脚本不写库' }, 409)
|
||||
})
|
||||
await page.route(/\/api\/apps\/catalog$/, (route) => json(route, { version: 1, components: [] }))
|
||||
await page.route(/\/api\/apps\/app-share(\/.*)?(\?.*)?$/, (route) => {
|
||||
const req = route.request()
|
||||
const parts = new URL(req.url()).pathname.split('/')
|
||||
const sub = parts[4]
|
||||
if (!sub) return json(route, app)
|
||||
if (sub === 'grants') return json(route, { items: [] })
|
||||
if (sub === 'assistant-conversation') return json(route, { id: 'conv-share', kind: 'app', title: '', turns: [] })
|
||||
if (sub === 'shares') {
|
||||
if (req.method() === 'GET') return json(route, { enabled, embed_origins: false, max_days: 90, items: state.items })
|
||||
if (req.method() === 'POST') {
|
||||
const body = req.postDataJSON()
|
||||
state.creates.push(body)
|
||||
const row = shareRow({ id: 'sh2', prefix: 'NeW456', mode: body.mode, embed_allowed: body.embed_allowed, open_count: 0,
|
||||
expires_at: body.expires_at })
|
||||
state.items = [row, ...state.items]
|
||||
return json(route, { ...row, key: 'NeW456-secret-share-key', path: '/s#NeW456-secret-share-key', url: null }, 201)
|
||||
}
|
||||
if (req.method() === 'DELETE') {
|
||||
state.revokes.push(parts[5])
|
||||
state.items = state.items.map((r) => (r.id === parts[5] ? { ...r, state: 'revoked', revoked_at: '2026-10-16T02:00:00Z' } : r))
|
||||
return json(route, state.items.find((r) => r.id === parts[5]))
|
||||
}
|
||||
}
|
||||
return route.fallback()
|
||||
})
|
||||
await page.goto(`${WEB}/apps/app-share/edit/access`, { waitUntil: 'networkidle' })
|
||||
return { page, state, errors }
|
||||
}
|
||||
|
||||
await section('应用编辑:管理员没开开关时不给新建,写明去哪开', async () => {
|
||||
const { page } = await openEditor({ enabled: false, items: [] })
|
||||
const box = page.locator('[data-app-shares="off"]')
|
||||
check('写明管理员没开、去哪开', await waitFor(page, async () => await box.count() === 1)
|
||||
&& (await box.innerText()).includes('允许建分享链接'))
|
||||
check('没有新建的入口', await page.locator('[data-app-share-create]').count() === 0)
|
||||
})
|
||||
|
||||
await section('应用编辑:建分享链接,链接只显示一次;列表与撤销', async () => {
|
||||
const { page, state, errors } = await openEditor()
|
||||
const row = page.locator('[data-app-share="AbC123"]')
|
||||
check('已有的分享列出状态、打开次数、建的人', await waitFor(page, async () => await row.count() === 1)
|
||||
&& (await row.innerText()).includes('有效') && (await row.innerText()).includes('打开过 4 次')
|
||||
&& (await row.innerText()).includes('由 管理员 建'))
|
||||
await page.locator('[data-app-share-create]').click()
|
||||
check('表单:选页面、数据、有效期、嵌入', await page.locator('[data-app-share-form]').count() === 1
|
||||
&& await page.locator('[data-app-share-days] option').count() === 4)
|
||||
await page.locator('[data-app-share-mode="fixed"]').check()
|
||||
await page.locator('[data-app-share-days]').selectOption('7')
|
||||
await page.locator('[data-app-share-submit]').click()
|
||||
check('建好后链接只显示这一次', await waitFor(page, async () => await page.locator('[data-app-share-once]').count() === 1)
|
||||
&& (await page.locator('[data-app-share-link]').innerText()).endsWith('/s#NeW456-secret-share-key')
|
||||
&& (await page.locator('[data-app-share-once]').innerText()).includes('只显示这一次'))
|
||||
const [body] = state.creates
|
||||
const days = body ? (new Date(body.expires_at).getTime() - Date.now()) / 86_400_000 : 0
|
||||
check('交给服务端的:页面、固定这一期、7 天后到期、不允许嵌入', body?.page_id === 'home' && body.mode === 'fixed'
|
||||
&& days > 6.9 && days < 7.1 && body.embed_allowed === false, JSON.stringify(body))
|
||||
await page.locator('[data-app-share-dismiss]').click()
|
||||
check('关掉之后链接就没有了', await page.locator('[data-app-share-link]').count() === 0
|
||||
&& await page.locator('[data-app-share="NeW456"]').count() === 1)
|
||||
await page.locator('[data-app-share-revoke="AbC123"]').click()
|
||||
const dialog = page.getByRole('alertdialog').or(page.getByRole('dialog'))
|
||||
check('撤销先确认,写明后果', await waitFor(page, async () => (await dialog.innerText()).includes('链接已撤销')))
|
||||
await dialog.getByRole('button', { name: '撤销' }).click()
|
||||
check('撤销了', await waitFor(page, async () => state.revokes.includes('sh1'))
|
||||
&& await waitFor(page, async () => await page.locator('[data-app-share="AbC123"][data-app-share-state="revoked"]').count() === 1))
|
||||
check('页面上没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))
|
||||
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
|
||||
await browser.close()
|
||||
process.exit(failed ? 1 : 0)
|
||||
Reference in New Issue
Block a user