From 4ea3a39e4100725e07e81ac7929233673b8fce83 Mon Sep 17 00:00:00 2001 From: smail Date: Sun, 6 Sep 2026 16:22:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E6=8F=92=E4=BB=B6=E4=B8=89?= =?UTF-8?q?=E7=BA=A7=E9=85=8D=E7=BD=AE=E8=8F=9C=E5=8D=95=E5=B9=B6=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E4=B8=89=E6=8F=92=E4=BB=B6=E6=9E=84=E5=BB=BA=E6=89=93?= =?UTF-8?q?=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 14 ++-- Makefile | 38 ++-------- deploy/build-plugins.mjs | 75 +++++++++++++++++++ deploy/build.ps1 | 4 +- deploy/package.mjs | 44 +++++++++++ deploy/start-dev.ps1 | 9 ++- docs/插件构建与配置.md | 50 +++++++++++++ .../ConfigSettings/ConfigForm/ConfigForm.js | 5 +- .../ConfigSettings/ConfigForm/ConfigForm.vue | 4 +- .../ConfigSettings/ConfigNavigation.js | 13 +++- .../ConfigSettings/ConfigSettings.js | 25 ++++++- .../PluginsConfig/PluginsConfig.vue | 3 +- .../SettingsSubmenu/SettingsSubmenu.css | 56 ++++++++++++++ .../SettingsSubmenu/SettingsSubmenu.js | 3 +- .../SettingsSubmenu/SettingsSubmenu.vue | 42 ++++++++--- test/frontend/config-form.test.mjs | 30 +++++++- test/frontend/config-navigation.test.mjs | 24 +++++- test/frontend/config-settings.test.mjs | 66 ++++++++++++++++ test/frontend/output-paths.test.mjs | 31 +++++++- test/frontend/settings-menu.test.mjs | 16 ++++ 20 files changed, 486 insertions(+), 66 deletions(-) create mode 100644 deploy/build-plugins.mjs create mode 100644 deploy/package.mjs create mode 100644 docs/插件构建与配置.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbcde0e..af786a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,13 +63,17 @@ jobs: BUILD_FLAGS="-s -w -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.Version=${{ env.VERSION }}' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.GoVersion=$(go version)' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.GitHash=$(git show -s --format=%H)' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)'" go build -ldflags "$BUILD_FLAGS" -o ../out/release/${{ env.FILENAME }} ./cmd/smail go build -ldflags "$BUILD_FLAGS" -o ../out/release/${{ env.ADMINFILENAME }} ./cmd/smail-admin - go build -ldflags "-s -w" -o ../out/release/plugins/${{ env.WCFILENAME }} hooks/wechat_push/wechat_push.go - go build -ldflags "-s -w" -o ../out/release/plugins/${{ env.SPAMBLOCKFILENAME }} hooks/spam_block/spam_block.go cd ../plugins/pmail_telegram_push npm ci - npm run build-only - go build -ldflags "-s -w" -o ../../out/release/plugins/${{ env.TELEGRAMFILENAME }} . - cd ../../out/release + npm run type-check + cd ../.. + node deploy/build-plugins.mjs --target=${{ matrix.goos }}_${{ matrix.goarch }} + for name in spam_block pmail_telegram_push wechat_push; do + extension="" + if [ "${{ matrix.goos }}" = "windows" ]; then extension=".exe"; fi + cp "out/plugins/${{ matrix.goos }}_${{ matrix.goarch }}/$name$extension" "out/release/plugins/$name$extension" + done + cd out/release cat > release.json < out/package/release.json +package: build_server plugin + SMAIL_VERSION="$(SMAIL_VERSION)" GIT_HASH="$(GIT_HASH)" node deploy/package.mjs test: node test/run.mjs all diff --git a/deploy/build-plugins.mjs b/deploy/build-plugins.mjs new file mode 100644 index 0000000..e32c0ba --- /dev/null +++ b/deploy/build-plugins.mjs @@ -0,0 +1,75 @@ +import { spawn } from 'node:child_process' +import { access, mkdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +export const root = fileURLToPath(new URL('../', import.meta.url)) +export const plugins = [ + { id: 'spam_block', executable: 'spam_block', directory: 'backend', source: './hooks/spam_block' }, + { id: 'telegram_push', executable: 'pmail_telegram_push', directory: 'plugins/pmail_telegram_push', source: '.' }, + { id: 'wechat_push', executable: 'wechat_push', directory: 'backend', source: './hooks/wechat_push' } +] +export const targets = ['linux_amd64', 'linux_arm64', 'windows_amd64', 'windows_arm64', 'darwin_amd64', 'darwin_arm64'] + +export function hostTarget() { + const goos = { win32: 'windows', linux: 'linux', darwin: 'darwin' }[process.platform] + const goarch = { x64: 'amd64', arm64: 'arm64' }[process.arch] + return `${goos}_${goarch}` +} + +export function buildPlan({ target = hostTarget(), plugin, development = false } = {}) { + if (!targets.includes(target)) throw new Error(`不支持的编译平台:${target}`) + if (plugin && !plugins.some(item => item.id === plugin)) throw new Error(`未知插件:${plugin}`) + if (development && target !== hostTarget()) throw new Error('开发运行目录只能使用本机平台插件') + const [goos, goarch] = target.split('_') + return plugins.filter(item => !plugin || item.id === plugin).map(item => ({ + ...item, + goos, + goarch, + output: path.join(root, 'out', 'plugins', ...(development ? [] : [target]), `${item.executable}${goos === 'windows' ? '.exe' : ''}`) + })) +} + +function run(command, args, cwd, env = process.env) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, env, stdio: 'inherit', windowsHide: true }) + child.once('error', reject) + child.once('exit', code => code === 0 ? resolve() : reject(new Error(`构建失败:${command},退出码 ${code}`))) + }) +} + +export async function buildPlugins(args) { + const options = {} + let all = false + for (const arg of args) { + if (arg === '--all') all = true + else if (arg === '--dev') options.development = true + else if (arg.startsWith('--target=')) options.target = arg.slice(9) + else if (arg.startsWith('--plugin=')) options.plugin = arg.slice(9) + else throw new Error(`未知构建参数:${arg}`) + } + if (all && (options.target || options.development)) throw new Error('全平台编译不能同时指定单个平台或开发目录') + const plan = (all ? targets : [options.target || hostTarget()]).flatMap(target => buildPlan({ ...options, target })) + if (plan.some(item => item.id === 'telegram_push')) { + const directory = path.join(root, 'plugins', 'pmail_telegram_push') + const vite = path.join(directory, 'node_modules', 'vite', 'bin', 'vite.js') + try { await access(vite) } catch { throw new Error('请先在 plugins/pmail_telegram_push 执行 npm ci 安装依赖') } + // 构建并同步 Telegram 单文件界面,保证 Go 嵌入的是本次资源。 + await run(process.execPath, [vite, 'build'], directory) + await run(process.execPath, [path.join(root, 'deploy', 'prepare-frontend.mjs'), 'telegram'], root) + } + for (const item of plan) { + await mkdir(path.dirname(item.output), { recursive: true }) + await run('go', ['build', '-ldflags', '-s -w', '-o', item.output, item.source], path.join(root, item.directory), { + ...process.env, CGO_ENABLED: '0', GOOS: item.goos, GOARCH: item.goarch + }) + console.log(`插件已编译:${path.relative(root, item.output)}`) + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + buildPlugins(process.argv.slice(2)).catch(error => { + console.error(error.message) + process.exitCode = 1 + }) +} diff --git a/deploy/build.ps1 b/deploy/build.ps1 index d3968cc..33d1466 100644 --- a/deploy/build.ps1 +++ b/deploy/build.ps1 @@ -20,4 +20,6 @@ try { & go build -o (Join-Path $output 'smail-config.exe') ./cmd/smail-config if ($LASTEXITCODE -ne 0) { throw '配置工具构建失败' } } finally { Pop-Location } -Write-Output '构建完成:out/frontend 和 out/backend。正式发行请使用 Makefile 的跨平台构建目标。' +& node (Join-Path $PSScriptRoot 'build-plugins.mjs') --dev +if ($LASTEXITCODE -ne 0) { throw '插件构建失败' } +Write-Output '构建完成:out/frontend、out/backend 和 out/plugins,包含三个内置插件。正式发行请使用 Makefile 的跨平台构建目标。' diff --git a/deploy/package.mjs b/deploy/package.mjs new file mode 100644 index 0000000..37a56e5 --- /dev/null +++ b/deploy/package.mjs @@ -0,0 +1,44 @@ +import { access, copyFile, mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { buildPlan, root, targets } from './build-plugins.mjs' + +export function packagePlan(target) { + const pluginPlan = buildPlan({ target }) + const [goos, goarch] = target.split('_') + const extension = goos === 'windows' ? '.exe' : '' + const suffix = `${goos === 'darwin' ? 'mac' : goos}_${goarch}${extension}` + const directory = path.join(root, 'out', 'package', target) + return [ + ...['smail', 'smail-admin'].map(name => ({ + source: path.join(root, 'out', 'backend', `${name}_${suffix}`), + target: path.join(directory, `${name}${extension}`) + })), + ...pluginPlan.map(item => ({ source: item.output, target: path.join(directory, 'plugins', path.basename(item.output)) })), + { source: path.join(root, 'README.md'), target: path.join(directory, 'README.md') } + ] +} + +export async function packageRelease(selectedTargets = targets) { + const plan = selectedTargets.flatMap(packagePlan) + // 所有输入齐备后才写发布目录,不把旧的或其他平台插件混入发布包。 + for (const item of plan) await access(item.source) + for (const item of plan) { + await mkdir(path.dirname(item.target), { recursive: true }) + await copyFile(item.source, item.target) + } + for (const target of selectedTargets) { + await writeFile(path.join(root, 'out', 'package', target, 'release.json'), JSON.stringify({ + version: process.env.SMAIL_VERSION || 'dev', gitHash: process.env.GIT_HASH || '', + protocolVersion: 1, target, plugins: buildPlan({ target }).map(item => item.id), createdAt: new Date().toISOString() + }, null, 2) + '\n') + } + console.log(`发布包已生成:${selectedTargets.map(target => `out/package/${target}`).join('、')}`) +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + packageRelease(process.argv.length > 2 ? process.argv.slice(2) : targets).catch(error => { + console.error(`发布打包失败:${error.message}`) + process.exitCode = 1 + }) +} diff --git a/deploy/start-dev.ps1 b/deploy/start-dev.ps1 index d6078cd..1698768 100644 --- a/deploy/start-dev.ps1 +++ b/deploy/start-dev.ps1 @@ -21,8 +21,13 @@ try { # SMAIL_ROOT 是 config 的父目录,不能指向 config 本身或编译输出目录。 $env:SMAIL_ROOT = $root $arguments = @('--dev-disable-admin-reset') - # 插件工作目录保持不变;配置、数据库和密钥统一通过 SMAIL_ROOT 定位。 - $workingDirectory = Join-Path $root 'backend' + # 插件从 out/plugins 加载;账号、配置和密钥仍通过 SMAIL_ROOT 定位到根目录 config。 + foreach ($name in @('spam_block.exe', 'pmail_telegram_push.exe', 'wechat_push.exe')) { + if (-not (Test-Path -LiteralPath (Join-Path $root "out\plugins\$name") -PathType Leaf)) { + throw '内置插件产物不完整,请先运行 deploy/build.ps1' + } + } + $workingDirectory = Join-Path $root 'out' } else { if (Get-NetTCPConnection -State Listen -LocalPort 15173 -ErrorAction SilentlyContinue) { throw '15173 端口已占用,请先确认并停止旧前端' } $binary = (Get-Command node -ErrorAction Stop).Source diff --git a/docs/插件构建与配置.md b/docs/插件构建与配置.md new file mode 100644 index 0000000..be429c2 --- /dev/null +++ b/docs/插件构建与配置.md @@ -0,0 +1,50 @@ +# 插件构建与配置 + +## 配置入口 + +在“设置 → 配置管理 → 插件配置”中选择三级菜单: + +- 垃圾邮件拦截 +- Telegram 推送 +- 微信推送 + +每次只显示所选插件的字段。切换页面前会提示未保存的编辑内容;三个插件仍共用后端插件配置分区,保存时保留其余插件、内部字段和权限。服务器存在候选配置时,页面会提示候选涉及哪些插件,应用时整个候选一起生效。 + +## Windows 开发构建 + +先安装 `frontend` 和 `plugins/pmail_telegram_push` 的前端依赖,然后在项目根目录运行: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File deploy/build.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File deploy/start-dev.ps1 -Service backend +``` + +修改后端或重新编译正在运行的插件前,先停止对应开发服务。`-SkipFrontend` 只跳过主前端,三个插件始终随开发构建编译,Telegram 的嵌入页面也会重新生成。 + +- 主前端:`out/frontend` +- 后端及管理工具:`out/backend` +- 三个插件:`out/plugins/spam_block.exe`、`out/plugins/pmail_telegram_push.exe`、`out/plugins/wechat_push.exe` +- 开发服务工作目录:`out`,保证相对路径 `plugins` 指向实际插件产物 +- `SMAIL_ROOT` 仍固定到项目根目录,现有账号、邮件数据库和运行配置保持在根目录 `config`,不能复制一份到 `out/config` + +## 跨平台构建与发布 + +```sh +make build +``` + +构建依赖顺序为:主前端 → 后端、三个插件 → 发布目录。使用并行构建时也不会在产物尚未生成前打包。 + +插件统一构建入口: + +```sh +node deploy/build-plugins.mjs --target=linux_amd64 +node deploy/build-plugins.mjs --target=windows_amd64 +node deploy/build-plugins.mjs --all +``` + +支持 Linux、Windows、macOS 的 amd64 和 arm64。插件产物位于 `out/plugins/<平台>_<架构>`,发布目录位于 `out/package/<平台>_<架构>`。每个平台的发布目录均包含主程序、管理工具、三个插件及发布元数据;插件使用后端目录中约定的固定文件名,不需要手动去掉平台后缀。 + +GitHub 发布工作流复用相同的插件构建脚本。三个插件保持独立进程,随主程序一起交付,并非合并进主程序执行文件;原有启停配置和进程隔离检查不变。编译成功不代表第三方服务已配置、推送已送达或生产平台隔离验收完成。Windows 生产模式现有的插件隔离限制仍然保留,不会因打包而绕过。 + +构建和打包不清理旧文件。旧版平铺产物会保留,但不会被新的按平台打包流程选入。 diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index 220b4e9..eee29f3 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -198,7 +198,8 @@ const pluginDefinitions = { } export function getFieldGroups(section, config) { - if (section !== 'plugins') { + const pluginId = Object.hasOwn(pluginDefinitions, section) ? section : '' + if (section !== 'plugins' && !pluginId) { return ({ base: baseGroups, smtp: smtpGroups, imap: imapGroups, pop3: pop3Groups, email: emailGroups, ssl: sslGroups }[section] || []) .map(item => ({ ...item, @@ -208,7 +209,7 @@ export function getFieldGroups(section, config) { })) .filter(item => item.fields.length > 0) } - return Object.entries(config.plugins || {}).map(([id, plugin]) => { + return Object.entries(config.plugins || {}).filter(([id]) => !pluginId || id === pluginId).map(([id, plugin]) => { const definition = pluginDefinitions[id] const prefix = ['plugins', id] const input = (name, label, type, extra = {}) => ({ path: [...prefix, name], label, type, ...extra }) diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.vue b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.vue index ece7a4a..96d67fd 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.vue +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.vue @@ -1,8 +1,8 @@ diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index 548f2b3..9c3ef6c 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -45,6 +45,34 @@ test('各协议仅编辑自己的监听端口,邮箱策略没有重复端口 assert.ok(validateFormConfig('smtp', { ...config, listener: { ...config.listener, tlsPort: 587 } })['listener.tlsPort']) }) +test('三个插件页面只展示和编辑自身字段,完整保留其他插件和未知字段', () => { + const config = { meta: { generation: 9 }, plugins: { + spam_block: { enabled: false, settings: { threshold: 20 }, secretStatus: { configured: true } }, + telegram_push: { enabled: false, settings: { timeout: 30, futureField: '保留值' } }, + wechat_push: { enabled: true, settings: { appId: 'test-only' } }, + future_plugin: { enabled: false, settings: ['原样保留'] } + } } + const text = JSON.stringify(config) + for (const id of ['spam_block', 'telegram_push', 'wechat_push']) { + const groups = getFieldGroups(id, config) + assert.equal(groups.length, 1) + assert.equal(groups[0].id, id) + assert.ok(groups[0].fields.every(field => field.path[1] === id)) + const edited = readFormConfig(updateFormConfig(text, id, ['plugins', id, 'enabled'], true)) + for (const other of Object.keys(config.plugins).filter(key => key !== id)) { + assert.deepEqual(edited.plugins[other], config.plugins[other]) + assert.equal(updateFormConfig(text, id, ['plugins', other, 'enabled'], true), text) + } + assert.deepEqual(edited.meta, config.meta) + assert.deepEqual(edited.plugins[id].settings, config.plugins[id].settings) + } + assert.deepEqual(getFieldGroups('wechat_push', { plugins: {} }), []) + const invalidOther = structuredClone(config) + invalidOther.plugins.telegram_push.settings.timeout = 999 + assert.deepEqual(validateFormConfig('spam_block', invalidOther), {}) + assert.ok(validateFormConfig('telegram_push', invalidOther)['plugins.telegram_push.settings.timeout']) +}) + test('文本输入的左右留白属于原生输入框,不影响数字、选择和开关控件', () => { const require = createRequire(new URL('../../frontend/package.json', import.meta.url)) const { parse, compileStyle } = require('@vue/compiler-sfc') @@ -85,7 +113,7 @@ test('分组不显示重复标题和介绍,保留无障碍名称、凭据状 assert.doesNotMatch(template, /]*:aria-label="group.title"/) - assert.match(template, /v-if="section === 'plugins'" class="credential-status"/) + assert.match(template, /v-if="group.secretConfigured !== undefined" class="credential-status"/) assert.match(template, /group.secretConfigured \? '凭据已配置' : '未配置凭据'/) assert.match(template, /v-if="group.note" class="form-group-note" role="note"/) assert.match(template, /v-if="!groups.length" class="form-empty"/) diff --git a/test/frontend/config-navigation.test.mjs b/test/frontend/config-navigation.test.mjs index 2eb4c1c..c604c7b 100644 --- a/test/frontend/config-navigation.test.mjs +++ b/test/frontend/config-navigation.test.mjs @@ -18,10 +18,20 @@ function fixture(capabilities = ['config.read']) { return { page, allowed } } -test('七个菜单对应七个不同页面组件,无权限的维护入口不可打开', async () => { +test('配置菜单包含三个独立插件三级页面,无权限的维护入口不可打开', async () => { const { page, allowed } = fixture() assert.equal(new Set(page.sectionOptions.value.map(item => item.component)).size, 7) assert.deepEqual(page.sectionOptions.value.map(item => item.value), ['base', 'smtp', 'imap', 'pop3', 'email', 'ssl', 'plugins']) + const children = page.sectionOptions.value.find(item => item.value === 'plugins').children + assert.deepEqual(children.map(item => item.value), ['spam_block', 'telegram_push', 'wechat_push']) + await page.selectSection('plugins') + assert.equal(page.selectedSection.value, 'spam_block') + assert.equal(page.activePage.value.label, '垃圾邮件拦截') + await page.selectSection('telegram_push') + assert.equal(page.activePage.value.label, 'Telegram 推送') + await page.selectSection('plugins') + assert.equal(page.selectedSection.value, 'telegram_push') + await page.selectSection('base') assert.equal(page.maintenanceOptions.value.length, 0) await page.selectSection('audit') await page.selectSection('invalid') @@ -34,6 +44,18 @@ test('七个菜单对应七个不同页面组件,无权限的维护入口不 assert.equal(page.activePage.value, undefined) }) +test('插件三级菜单切换保留离开确认及权限检查', async () => { + const { page, allowed } = fixture() + await page.selectSection('wechat_push') + page.currentPage.value = { beforeLeave: async () => false } + await page.selectSection('spam_block') + assert.equal(page.selectedSection.value, 'wechat_push') + page.currentPage.value = { beforeLeave: async () => { allowed.delete('config.read'); return true } } + await page.selectSection('telegram_push') + assert.equal(page.selectedSection.value, 'wechat_push') + assert.equal(page.activePage.value, undefined) +}) + test('切页等待离开确认,取消和快速重复点击不会更改页面', async () => { const { page } = fixture() let resolve diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs index 26b518d..be4a820 100644 --- a/test/frontend/config-settings.test.mjs +++ b/test/frontend/config-settings.test.mjs @@ -684,3 +684,69 @@ test('共用邮箱分区的候选明确提示其他页面的改动,忽略代 const template = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue', import.meta.url), 'utf8') assert.match(template, /:title="pendingScopeHint"/) }) + +for (const key of ['spam_block', 'telegram_push', 'wechat_push']) { + test(`${key} 独立页面保留整个插件分区,验证、候选恢复和应用使用统一接口`, async () => { + let active = { meta: { generation: 1 }, plugins: { + spam_block: { enabled: false, executable: 'spam_block.exe', permissions: { network: [] }, settings: { threshold: 20 } }, + telegram_push: { enabled: false, executable: 'pmail_telegram_push.exe', settings: { timeout: 30 }, secretStatus: { configured: true }, settingsConfigured: true }, + wechat_push: { enabled: false, executable: 'wechat_push.exe', settings: { appId: 'test-only', future: 'preserved' } } + } } + const original = structuredClone(active) + let pending = null + const requests = [] + const http = { + get: async path => { + requests.push(path) + return { errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: active.meta.generation } + : path.endsWith('/pending') ? pending : active } + }, + put: async (path, body) => { + assert.equal(path, '/api/v1/config/plugins') + assert.equal(body.expectedGeneration, 1) + assert.deepEqual(body.secretChanges, {}) + assert.equal(body.payload.plugins.telegram_push.secretStatus, undefined) + assert.equal(body.payload.plugins.telegram_push.settingsConfigured, undefined) + for (const id of Object.keys(original.plugins)) { + assert.deepEqual(body.payload.plugins[id].settings, original.plugins[id].settings) + assert.equal(body.payload.plugins[id].enabled, id === key) + } + pending = { generation: 2, section: 'plugins', sameSession: true, candidateId: 'plugin-test', payload: body.payload } + return { errorNo: 0, data: pending } + }, + post: async (path, body) => { + requests.push(path) + if (path.endsWith('/apply')) { + assert.equal(body.generation, 2) + assert.equal(body.candidateId, 'plugin-test') + active = pending.payload + active.meta.generation = 2 + pending = null + } + return { errorNo: 0, data: { valid: true } } + } + } + const { page, messages, dispose } = fixture(() => {}, key, { http }) + await page.loadAll() + assert.deepEqual(requests, ['/api/v1/config/status', '/api/v1/config/plugins', '/api/v1/config/pending']) + assert.equal(page.selectedSection.value, key) + page.updateField(['plugins', key, 'enabled'], true) + await page.validateConfig() + await page.saveConfig() + assert.ok(requests.includes('/api/v1/config/plugins/validate')) + assert.match(page.pendingScopeHint.value, new RegExp(`本次候选涉及:${page.currentSection.value.label}。`)) + const other = Object.keys(original.plugins).find(id => id !== key) + page.updateField(['plugins', other, 'enabled'], true) + assert.equal(page.formConfig.value.plugins[other].enabled, false) + dispose() + const restored = fixture(() => {}, other, { http }).page + await restored.loadAll() + assert.equal(restored.savedGeneration.value, 2) + assert.match(restored.pendingScopeHint.value, /三个插件共用一个配置分区/) + assert.ok(restored.pendingScopeHint.value.includes(page.currentSection.value.label)) + await restored.applyConfig() + assert.ok(requests.includes('/api/v1/config/plugins/apply')) + assert.equal(restored.savedGeneration.value, 0) + assert.deepEqual(messages, []) + }) +} diff --git a/test/frontend/output-paths.test.mjs b/test/frontend/output-paths.test.mjs index 6654fd0..fd54219 100644 --- a/test/frontend/output-paths.test.mjs +++ b/test/frontend/output-paths.test.mjs @@ -3,6 +3,8 @@ import { readFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import test from 'node:test' +import { buildPlan, hostTarget, plugins, targets } from '../../deploy/build-plugins.mjs' +import { packagePlan } from '../../deploy/package.mjs' const root = fileURLToPath(new URL('../../', import.meta.url)) const read = relative => readFile(path.join(root, relative), 'utf8') @@ -10,7 +12,7 @@ const read = relative => readFile(path.join(root, relative), 'utf8') test('跨平台构建输出不得越过项目 out 目录', async () => { const content = await read('Makefile') const commands = [...content.matchAll(/cd ([^\s]+) && [^\n]*go build [^\n]* -o ([^\s]+) /g)] - assert.equal(commands.length, 30) + assert.equal(commands.length, 12) for (const [, directory, output] of commands) { const relative = path.relative(path.join(root, 'out'), path.resolve(root, directory, output)) assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative), output) @@ -31,13 +33,38 @@ test('开发启动必须固定运行配置根目录并集中保存日志', async assert.match(content, /\$env:SMAIL_ROOT = \$root\r?\n/) assert.match(content, /\$legacyConfig = Join-Path \$root 'backend\\config'/) assert.match(content, /Test-Path -LiteralPath \(Join-Path \$legacyConfig \$name\)/) - assert.match(content, /\$workingDirectory = Join-Path \$root 'backend'/) + assert.match(content, /\$workingDirectory = Join-Path \$root 'out'/) + for (const name of ['spam_block.exe', 'pmail_telegram_push.exe', 'wechat_push.exe']) assert.ok(content.includes(name)) assert.match(content, /out\\backend\\smail-dev\.exe/) assert.match(content, /test-log\\runtime/) assert.match(content, /-WindowStyle Hidden/) assert.ok(content.charCodeAt(0) === 0xfeff, 'Windows PowerShell 5 脚本应有 UTF-8 BOM') }) +test('六个平台默认包含三个插件,构建和发布名称与后端目录完全匹配', async () => { + const catalog = await read('backend/plugins/catalog/catalog.go') + for (const target of targets) { + const plan = buildPlan({ target }) + assert.equal(plan.length, 3) + assert.deepEqual(plan.map(item => item.id), plugins.map(item => item.id)) + for (const item of plan) { + assert.equal(path.dirname(item.output), path.join(root, 'out', 'plugins', target)) + assert.ok(catalog.includes(`"${path.basename(item.output)}"`)) + } + const files = packagePlan(target) + assert.equal(files.length, 6) + assert.equal(files.filter(file => path.basename(path.dirname(file.target)) === 'plugins').length, 3) + assert.ok(files.every(file => file.target.startsWith(path.join(root, 'out', 'package', target)))) + } + assert.ok(buildPlan({ development: true }).every(item => path.dirname(item.output) === path.join(root, 'out', 'plugins'))) + assert.throws(() => buildPlan({ target: '../invalid' })) + assert.throws(() => buildPlan({ plugin: 'unknown' })) + assert.throws(() => buildPlan({ development: true, target: targets.find(target => target !== hostTarget()) })) + assert.match(await read('deploy/build.ps1'), /build-plugins\.mjs'\) --dev/) + assert.match(await read('Makefile'), /package: build_server plugin/) + assert.match(await read('.github/workflows/release.yml'), /node deploy\/build-plugins\.mjs --target=/) +}) + test('安全工作流必须先构建嵌入资源再执行 Go 测试', async () => { for (const file of ['.github/workflows/release.yml', '.github/workflows/security.yml']) { const content = (await read(file)).split(' security:')[1] diff --git a/test/frontend/settings-menu.test.mjs b/test/frontend/settings-menu.test.mjs index 0b22c02..fc69abd 100644 --- a/test/frontend/settings-menu.test.mjs +++ b/test/frontend/settings-menu.test.mjs @@ -62,6 +62,22 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => { assert.equal(page.activeSettings.value, undefined) }) +test('三级菜单只发送当前分组的可见子项,选中状态仍由父页面确认', () => { + const props = reactive({ modelValue: 'spam_block', items: [{ value: 'plugins', children: [{ value: 'spam_block' }, { value: 'telegram_push' }] }] }) + const events = [] + const { select } = useSubmenu(props, (...event) => events.push(event)) + select('unknown') + select('spam_block') + select('telegram_push') + assert.deepEqual(events, [['select', 'telegram_push']]) + assert.equal(props.modelValue, 'spam_block') + props.items = [] + select('telegram_push') + assert.equal(events.length, 1) + assert.match(read('SettingsSubmenu/SettingsSubmenu.vue'), /settings-tertiary-list/) + assert.match(read('SettingsSubmenu/SettingsSubmenu.css'), /\.settings-tertiary-item:focus-visible/) +}) + test('菜单模板可编译并保留按钮、当前项语义及原有选区处理器', () => { for (const name of ['HomeHeader', 'SettingsSubmenu', 'SecuritySettings', 'ConfigSettings']) { const source = read(`${name}/${name}.vue`)