import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); const checkerPath = path.resolve('tools/boundaries/verify-project-boundaries.mjs'); async function createFixture() { const repositoryRoot = await mkdtemp(path.join(tmpdir(), 'a-share-boundary-')); await mkdir(path.join(repositoryRoot, 'web/src/api/generated'), { recursive: true }); await mkdir(path.join(repositoryRoot, 'server/libs'), { recursive: true }); await writeFile( path.join(repositoryRoot, 'web/src/api/generated/schema.ts'), 'export interface paths {}\n', ); return repositoryRoot; } async function runChecker(repositoryRoot) { try { await execFileAsync(process.execPath, [checkerPath, '--root', repositoryRoot]); return { exitCode: 0, stderr: '' }; } catch (error) { return { exitCode: error.code, stderr: String(error.stderr), }; } } test('拒绝 Web 通过静态、动态或导出语句越界依赖 Server', async () => { const repositoryRoot = await createFixture(); await writeFile( path.join(repositoryRoot, 'web/src/illegal.ts'), [ "import '../../server/libs/static';", "export { value } from '../../server/libs/exported';", "void import('../../server/libs/dynamic');", ].join('\n'), ); const result = await runChecker(repositoryRoot); assert.equal(result.exitCode, 1); assert.match(result.stderr, /Web 禁止依赖 Server/); assert.match(result.stderr, /illegal\.ts/); }); test('拒绝生成契约泄漏 RocketMQ 内部消息结构', async () => { const repositoryRoot = await createFixture(); await writeFile( path.join(repositoryRoot, 'web/src/api/generated/schema.ts'), 'export interface MessageEnvelope { messageId: string }\n', ); const result = await runChecker(repositoryRoot); assert.equal(result.exitCode, 1); assert.match(result.stderr, /生成契约包含 Server 内部消息/); }); test('拒绝通过 tsconfig 路径别名依赖 Server', async () => { const repositoryRoot = await createFixture(); await writeFile( path.join(repositoryRoot, 'web/tsconfig.json'), JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@server/*': ['../server/*'] }, }, }), ); await writeFile( path.join(repositoryRoot, 'web/src/aliased.ts'), "import type { Internal } from '@server/libs/internal';\nexport type Leaked = Internal;\n", ); await writeFile( path.join(repositoryRoot, 'server/libs/internal.ts'), 'export interface Internal { secret: string }\n', ); const result = await runChecker(repositoryRoot); assert.equal(result.exitCode, 1); assert.match(result.stderr, /Web 禁止依赖 Server/); }); test('拒绝 Vue 单文件组件脚本越界依赖 Server', async () => { const repositoryRoot = await createFixture(); await writeFile( path.join(repositoryRoot, 'web/src/IllegalView.vue'), ` `, ); const result = await runChecker(repositoryRoot); assert.equal(result.exitCode, 1); assert.match(result.stderr, /Web 禁止依赖 Server/); assert.match(result.stderr, /IllegalView\.vue/); }); for (const [name, scriptTag] of [ ['双引号', '\n\n`, ); const result = await runChecker(repositoryRoot); assert.equal(result.exitCode, 1); assert.match(result.stderr, /禁止使用外部 script src/); assert.match(result.stderr, /ExternalScript\.vue/); }); } test('接受只消费公开 OpenAPI 类型的 Web 工程', async () => { const repositoryRoot = await createFixture(); await writeFile( path.join(repositoryRoot, 'web/src/legal.ts'), "import type { paths } from './api/generated/schema';\nexport type ApiPaths = paths;\n", ); const result = await runChecker(repositoryRoot); assert.equal(result.exitCode, 0); assert.equal(result.stderr, ''); }); for (const [name, relativePath, source, expectedViolation] of [ [ '页面直接导入 Axios', 'web/src/views/MarketView.vue', ` `, 'Web 运行时代码禁止直接使用 Axios', ], [ 'Store 直接调用 fetch', 'web/src/stores/market.ts', "export const loadMarket = () => fetch('/api/v1/market');\n", 'Web 运行时代码禁止直接调用 fetch', ], [ '页面通过 globalThis 调用 fetch', 'web/src/views/GlobalFetchView.ts', "export const loadMarket = () => globalThis.fetch('/api/v1/market');\n", 'Web 运行时代码禁止直接调用 fetch', ], [ 'Composable 自行创建 Axios 实例', 'web/src/composables/use-market.ts', "import axios from 'axios';\nexport const client = axios.create();\n", 'Web 运行时代码禁止直接使用 Axios', ], [ '页面把业务 API 路径交给其他函数', 'web/src/views/MarketView.ts', "declare function load(path: string): void;\nload('/api/v1/market');\n", '业务 API 路径只能声明在 web/src/api', ], [ '业务 API 绕过统一 request', 'web/src/api/market/index.ts', "declare function send(path: string): void;\nexport const load = () => send('/api/v1/market');\n", '业务 API 文件必须使用 @/utils/request', ], [ '业务 API 通过配置对象绕过统一 request', 'web/src/api/market/config.ts', "declare function send(config: { url: string }): void;\nexport const load = () => send({ url: '/api/v1/market' });\n", '业务 API 文件必须使用 @/utils/request', ], ]) { test(`拒绝${name}`, async () => { const repositoryRoot = await createFixture(); const sourcePath = path.join(repositoryRoot, relativePath); await mkdir(path.dirname(sourcePath), { recursive: true }); await writeFile(sourcePath, source); const result = await runChecker(repositoryRoot); assert.equal(result.exitCode, 1); assert.match(result.stderr, new RegExp(expectedViolation)); assert.match(result.stderr, new RegExp(path.basename(relativePath).replace('.', '\\.'))); }); } test('接受 Axios 封装、业务 API 和页面消费的三层边界', async () => { const repositoryRoot = await createFixture(); await mkdir(path.join(repositoryRoot, 'web/src/utils'), { recursive: true }); await mkdir(path.join(repositoryRoot, 'web/src/api/health'), { recursive: true }); await mkdir(path.join(repositoryRoot, 'web/src/views'), { recursive: true }); await writeFile( path.join(repositoryRoot, 'web/src/utils/request.ts'), "import axios from 'axios';\nexport default axios.create();\n", ); await writeFile( path.join(repositoryRoot, 'web/src/api/health/index.ts'), "import request from '@/utils/request';\nexport const getHealth = () => request.get('/api/v1/health');\n", ); await writeFile( path.join(repositoryRoot, 'web/src/views/HealthView.ts'), "import { getHealth } from '@/api/health';\nexport const load = () => getHealth();\n", ); const result = await runChecker(repositoryRoot); assert.equal(result.exitCode, 0); assert.equal(result.stderr, ''); });