230 lines
7.8 KiB
JavaScript
230 lines
7.8 KiB
JavaScript
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'),
|
|
`<script setup lang="ts">
|
|
import type { Internal } from '../../server/libs/internal';
|
|
const value: Internal | undefined = undefined;
|
|
</script>
|
|
<template><main>{{ value }}</main></template>
|
|
`,
|
|
);
|
|
|
|
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 [
|
|
['双引号', '<script lang="ts" src="../../server/libs/internal.ts">'],
|
|
['单引号', "<script src='../../server/libs/internal.ts' lang='ts'>"],
|
|
['无引号且属性换序', '<script lang=ts src=../../server/libs/internal.ts>'],
|
|
]) {
|
|
test(`拒绝 Vue 外部 script src 通过${name}绕过源码边界`, async () => {
|
|
const repositoryRoot = await createFixture();
|
|
await writeFile(
|
|
path.join(repositoryRoot, 'web/src/ExternalScript.vue'),
|
|
`${scriptTag}</script>\n<template><main /></template>\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',
|
|
`<script setup lang="ts">
|
|
import axios from 'axios';
|
|
void axios.get('/api/v1/market');
|
|
</script>
|
|
<template><main /></template>
|
|
`,
|
|
'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, '');
|
|
});
|