@taicode/common-server 4.0.0-beta.4 → 4.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/output/app/index.d.ts +2 -1
- package/output/app/index.d.ts.map +1 -1
- package/output/app/index.js +47 -14
- package/output/auth/index.d.ts +3 -3
- package/output/auth/index.d.ts.map +1 -1
- package/output/auth/index.js +3 -2
- package/output/core/boot/context.d.ts +54 -34
- package/output/core/boot/context.d.ts.map +1 -1
- package/output/core/boot/context.js +42 -4
- package/output/core/boot/lifecycle.d.ts +39 -7
- package/output/core/boot/lifecycle.d.ts.map +1 -1
- package/output/core/boot/lifecycle.js +59 -19
- package/output/core/boot/shutdown.d.ts +9 -5
- package/output/core/boot/shutdown.d.ts.map +1 -1
- package/output/core/boot/shutdown.js +9 -4
- package/output/core/boot/signal.d.ts +1 -1
- package/output/core/boot/signal.js +1 -1
- package/output/core/boot/steps.d.ts +60 -29
- package/output/core/boot/steps.d.ts.map +1 -1
- package/output/core/boot/steps.js +72 -13
- package/output/core/entries.d.ts +49 -22
- package/output/core/entries.d.ts.map +1 -1
- package/output/core/entries.js +64 -30
- package/output/core/entries.test.js +251 -10
- package/output/core/env.d.ts +21 -0
- package/output/core/env.d.ts.map +1 -1
- package/output/core/env.js +28 -0
- package/output/core/resource.d.ts +56 -0
- package/output/core/resource.d.ts.map +1 -0
- package/output/core/resource.js +56 -0
- package/output/core/test-support/database.d.ts +2 -9
- package/output/core/test-support/database.d.ts.map +1 -1
- package/output/core/test-support/database.js +3 -16
- package/output/core/test-support/database.test.js +2 -1
- package/output/core/test-support/environment.d.ts +8 -3
- package/output/core/test-support/environment.d.ts.map +1 -1
- package/output/core/test-support/environment.js +10 -7
- package/output/core/test-support/test-server.d.ts +49 -19
- package/output/core/test-support/test-server.d.ts.map +1 -1
- package/output/core/test-support/test-server.js +22 -6
- package/output/core/test-support/test-server.test.js +7 -5
- package/output/core/test-support/testdata.d.ts +148 -0
- package/output/core/test-support/testdata.d.ts.map +1 -0
- package/output/core/test-support/testdata.js +202 -0
- package/output/core/test-support/testdata.test.d.ts +2 -0
- package/output/core/test-support/testdata.test.d.ts.map +1 -0
- package/output/core/test-support/testdata.test.js +220 -0
- package/output/core/types.d.ts +235 -71
- package/output/core/types.d.ts.map +1 -1
- package/output/cors/index.d.ts +2 -2
- package/output/cors/index.d.ts.map +1 -1
- package/output/cors/index.js +2 -1
- package/output/rate-limit/index.d.ts +2 -2
- package/output/rate-limit/index.d.ts.map +1 -1
- package/output/rate-limit/index.js +2 -1
- package/package.json +14 -15
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { LoggerLike } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* **测试数据(testdata)机制**:`packages/server/testdata/*.ts|js` 下的脚本,在 `development` /
|
|
4
|
+
* `test` 启动时自动加载、在测试用例之间自动清理。
|
|
5
|
+
*
|
|
6
|
+
* ## 为什么是「目录 + 两个约定方法」,而不是资源上的生命周期
|
|
7
|
+
*
|
|
8
|
+
* 「这条用例需要哪些样本数据」与「这份数据怎么删掉」是**同一件事的正反面**,写在同一个文件里
|
|
9
|
+
* 才不容易漏。把 `cleanup` 挂到资源单元上做不到这一点:资源只知道「有哪些表 / 哪些 key」,
|
|
10
|
+
* 不知道「谁往里写了什么」——新增一个 model 就要回去改一次清库清单,漏改只会安静地变脏。
|
|
11
|
+
*
|
|
12
|
+
* 所以机制倒过来:**数据脚本自己负责自己**。
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* // packages/server/testdata/01-base.ts
|
|
16
|
+
* import { PrismaClient } from '@prisma/generated/client'
|
|
17
|
+
*
|
|
18
|
+
* export async function load() { …插入样本数据… }
|
|
19
|
+
* export async function cleanup() { …删掉自己插入的数据… }
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* ## 契约(只有三条)
|
|
23
|
+
*
|
|
24
|
+
* 1. **目录固定**:`<cwd>/testdata`(`tsx watch ./source/index.ts` 与 `vitest` 的 cwd 都是
|
|
25
|
+
* `packages/server`)。目录不存在 = 没有脚本。
|
|
26
|
+
* 2. **两个方法都必需**:`load()` 与 `cleanup()` 缺一个就**报错**(不是跳过)。只写 `load`
|
|
27
|
+
* 的脚本意味着「这份数据删不掉」,那正是本机制要消灭的东西。
|
|
28
|
+
* 3. **顺序**:文件名(字典序升序)是默认序;需要「我必须排在某脚本之后」时用
|
|
29
|
+
* `dependsOn` 显式声明,见下节。
|
|
30
|
+
*
|
|
31
|
+
* ## 顺序:`dependsOn` 是权威,文件名只做同层破平
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* // packages/server/testdata/02-organization.ts
|
|
35
|
+
* export const dependsOn = ['01-database']
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* - **引用名 = 去扩展名的文件名**(`01-base.ts` → `'01-base'`),与 `TestDataUnit.name` 一致。
|
|
39
|
+
* - **只影响顺序,不注入任何东西**:声明依赖**不会**把前一个脚本的客户端/数据递给你。脚本仍然
|
|
40
|
+
* 自己建自己的连接(只依赖 env),否则 `testdata/` 就不再是可独立运行的样本数据。
|
|
41
|
+
* - **`cleanup` 不用声明**:它是装配序的逆序,依赖自动反向成立(后建的先拆)。
|
|
42
|
+
* - **同层按文件名升序**:没有依赖关系的脚本之间的相对顺序由文件名决定,结果确定可复现。
|
|
43
|
+
* - **缺依赖 / 自依赖 / 环一律报错**(不是跳过、不是猜测顺序):顺序是安全属性,猜错只会
|
|
44
|
+
* 变成偶发的外键冲突。
|
|
45
|
+
*
|
|
46
|
+
* 为什么要有这个字段:数字前缀是「顺序的替身」,中间插一个脚本就得重命名后面全部文件——
|
|
47
|
+
* 纯噪音改动,还会切断 git 历史。依赖声明让「谁必须在谁之后」贴着原因写。
|
|
48
|
+
*
|
|
49
|
+
* > 字段名与已删除的**模块装配期** `dependsOn`(连同 `validateLifecycleDependencies`,§11-2)
|
|
50
|
+
* > 无关:那个是资源/模块之间的构造依赖,这个是数据脚本之间的**执行顺序**。
|
|
51
|
+
*
|
|
52
|
+
* ## 两条与「安全」有关的约定
|
|
53
|
+
*
|
|
54
|
+
* - **`_` 开头的文件不是脚本**:它们是脚本之间共享的 helper(`_client.ts`),不会被 `load` /
|
|
55
|
+
* `cleanup`。否则扫描目录就会把工具文件也当成脚本执行。
|
|
56
|
+
* - **只在可清理环境执行**:`load` / `cleanup` / `reset` 三个执行入口的**第 0 步**都是
|
|
57
|
+
* `assertCleanableEnvironment(env)`。`env` 是**必填参数**而不是从 `process.env` 现读——
|
|
58
|
+
* 漏传即编译错误,且判据是装配期的快照(事后改写 `process.env` 不影响结论)。
|
|
59
|
+
*
|
|
60
|
+
* ## 执行顺序与失败语义
|
|
61
|
+
*
|
|
62
|
+
* | 入口 | 顺序 | 失败 |
|
|
63
|
+
* | --- | --- | --- |
|
|
64
|
+
* | `loadTestData` | **执行序**(`dependsOn` 拓扑序,同层按文件名升序) | 立即冒泡 |
|
|
65
|
+
* | `cleanupTestData` | **执行序逆序** | 立即冒泡 |
|
|
66
|
+
* | `resetTestData` | 先整体 `cleanup`(逆序),再整体 `load`(正序) | 立即冒泡 |
|
|
67
|
+
*
|
|
68
|
+
* `cleanup` 逆序是「后建的先拆」,依赖因此不必反向声明;`reset` 合成一个入口是给
|
|
69
|
+
* `beforeEach` 用的——一条语句就能拿到干净基线,不需要调用方自己记「先清后载」。
|
|
70
|
+
*
|
|
71
|
+
* 失败**一律冒泡**,不降级成「跳过这条脚本」:数据没准备好时静默继续只会让后续用例读到脏状态,
|
|
72
|
+
* 制造伪绿。
|
|
73
|
+
*/
|
|
74
|
+
export interface TestDataScript {
|
|
75
|
+
/** 加载本脚本负责的测试数据(可重复执行:`reset` 会反复调它) */
|
|
76
|
+
load(): void | Promise<void>;
|
|
77
|
+
/** 清理本脚本负责的测试数据(必须能把 `load()` 的效果完全撤销) */
|
|
78
|
+
cleanup(): void | Promise<void>;
|
|
79
|
+
/**
|
|
80
|
+
* 必须在哪些脚本**之后**执行(去扩展名的文件名;缺省 = 无要求)。
|
|
81
|
+
*
|
|
82
|
+
* 只影响排序:`load` 按依赖拓扑序(同层按文件名升序),`cleanup` 取其逆序。
|
|
83
|
+
* 校验严格:引用不存在的脚本、引用自己、循环依赖都会让装载阶段**抛错**。
|
|
84
|
+
*/
|
|
85
|
+
readonly dependsOn?: readonly string[];
|
|
86
|
+
}
|
|
87
|
+
/** 一个已从磁盘装载好的脚本单元(含名字,用于日志与报错定位) */
|
|
88
|
+
export interface TestDataUnit extends TestDataScript {
|
|
89
|
+
/** 脚本文件名去掉扩展名(`01-base.ts` → `01-base`) */
|
|
90
|
+
readonly name: string;
|
|
91
|
+
}
|
|
92
|
+
/** 三个执行入口的共同入参 */
|
|
93
|
+
export interface TestDataRunParams {
|
|
94
|
+
/**
|
|
95
|
+
* 本次装配的 `env.ENV`(`ctx.env.ENV`)。**必填**:破坏性动作没有环境就没法判断该不该跑。
|
|
96
|
+
*
|
|
97
|
+
* 非 `CLEANABLE_ENVS`(`development` / `test`)一律抛错,一个脚本都不跑。
|
|
98
|
+
*/
|
|
99
|
+
env: string;
|
|
100
|
+
/** 已装载的脚本(`discoverTestDataModules()` 的产物);缺省 = 没有脚本 */
|
|
101
|
+
units?: readonly TestDataUnit[];
|
|
102
|
+
/** 日志标签(`defineServer({ app: { service } })`) */
|
|
103
|
+
service?: string;
|
|
104
|
+
/** 记录型 logger(`ctx.log`);缺省 = 不打日志 */
|
|
105
|
+
log?: LoggerLike;
|
|
106
|
+
}
|
|
107
|
+
/** 默认脚本目录(相对 `process.cwd()`) */
|
|
108
|
+
export declare function resolveTestDataDirectory(): string;
|
|
109
|
+
/**
|
|
110
|
+
* 扫描目录并**装载**全部脚本(`import()` 一次,模块本身由 ESM 缓存复用)。
|
|
111
|
+
*
|
|
112
|
+
* 目录不存在 → 空数组(不是错误:没写测试数据的服务是合法的)。
|
|
113
|
+
* 文件存在但 `load` / `cleanup` 缺一个 → **抛错**,并指出是哪个文件缺哪个方法。
|
|
114
|
+
*
|
|
115
|
+
* 返回的顺序就是**执行序**:`dependsOn` 拓扑序 + 同层文件名升序(`load` 直接正序跑,
|
|
116
|
+
* `cleanup` 直接逆序跑)。
|
|
117
|
+
*/
|
|
118
|
+
export declare function discoverTestDataModules(directory?: string): Promise<TestDataUnit[]>;
|
|
119
|
+
/**
|
|
120
|
+
* 加载测试数据:按**执行序**逐项 `await load()`(依赖在前,同层按文件名升序)。
|
|
121
|
+
*
|
|
122
|
+
* 在 `createTestServer()` 装配完成(`ready()` 之后)自动执行一次;也可以由测试自己再调。
|
|
123
|
+
*/
|
|
124
|
+
export declare function loadTestData(params: TestDataRunParams): Promise<void>;
|
|
125
|
+
/**
|
|
126
|
+
* 清理测试数据:按执行序**逆序**逐项 `await cleanup()`(后建的先拆)。
|
|
127
|
+
*
|
|
128
|
+
* 依赖不必反向声明:`load` 的拓扑序取逆就是安全的拆除序。
|
|
129
|
+
*
|
|
130
|
+
* 单独调用它的场景很少(多半是要 `resetTestData`):它与 `loadTestData` 成对,
|
|
131
|
+
* 语义是「把脚本插入的数据撤掉」,而不是「把整个库清空」。
|
|
132
|
+
*/
|
|
133
|
+
export declare function cleanupTestData(params: TestDataRunParams): Promise<void>;
|
|
134
|
+
/**
|
|
135
|
+
* 重置测试数据:先**整体** `cleanup`(逆序),再**整体** `load`(正序)。
|
|
136
|
+
*
|
|
137
|
+
* 这是 `beforeEach` 的标准动作(`TestServer.resetTestData()` 的底层实现):
|
|
138
|
+
* 先全部撤掉再全部重建,而不是「逐脚本 cleanup + load」——后者会让前一个脚本重新插入的数据
|
|
139
|
+
* 被后一个脚本的 `cleanup` 顺手删掉(顺序依赖)。分两趟就没有这个问题。
|
|
140
|
+
*
|
|
141
|
+
* ## 两点与「安全」有关的说明
|
|
142
|
+
*
|
|
143
|
+
* - **先在清理阶段**:万一 `load` 失败,库里留下的是「干净」而不是「半套数据 + 半套旧数据」。
|
|
144
|
+
* - **闸门只判一次**(三个入口各自开头),不做逐脚本判定:同一批脚本在同一环境里跑,
|
|
145
|
+
* 逐个判只是把一次判定复制 N 次。
|
|
146
|
+
*/
|
|
147
|
+
export declare function resetTestData(params: TestDataRunParams): Promise<void>;
|
|
148
|
+
//# sourceMappingURL=testdata.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testdata.d.ts","sourceRoot":"","sources":["../../../source/core/test-support/testdata.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAE7C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuEG;AACH,MAAM,WAAW,cAAc;IAC7B,wCAAwC;IACxC,IAAI,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5B,0CAA0C;IAC1C,OAAO,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/B;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CACvC;AAED,oCAAoC;AACpC,MAAM,WAAW,YAAa,SAAQ,cAAc;IAClD,2CAA2C;IAC3C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED,kBAAkB;AAClB,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,GAAG,EAAE,MAAM,CAAA;IACX,wDAAwD;IACxD,KAAK,CAAC,EAAE,SAAS,YAAY,EAAE,CAAA;IAC/B,iDAAiD;IACjD,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,sCAAsC;IACtC,GAAG,CAAC,EAAE,UAAU,CAAA;CACjB;AAED,iCAAiC;AACjC,wBAAgB,wBAAwB,IAAI,MAAM,CAEjD;AA8FD;;;;;;;;GAQG;AACH,wBAAsB,uBAAuB,CAAC,SAAS,GAAE,MAAmC,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAgDrH;AAeD;;;;GAIG;AACH,wBAAsB,YAAY,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAO3E;AAED;;;;;;;GAOG;AACH,wBAAsB,eAAe,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAM9E;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,aAAa,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAW5E"}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { basename, extname, join, resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { assertCleanableEnvironment } from '../env.js';
|
|
5
|
+
/** 默认脚本目录(相对 `process.cwd()`) */
|
|
6
|
+
export function resolveTestDataDirectory() {
|
|
7
|
+
return resolve(process.cwd(), DEFAULT_TESTDATA_DIRECTORY);
|
|
8
|
+
}
|
|
9
|
+
const DEFAULT_TESTDATA_DIRECTORY = 'testdata';
|
|
10
|
+
/** 会被当作脚本的扩展名 */
|
|
11
|
+
const TESTDATA_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'];
|
|
12
|
+
/** `_` 前缀 = 共享 helper,不参与 load / cleanup */
|
|
13
|
+
const HELPER_PREFIX = '_';
|
|
14
|
+
function isTestDataFile(name) {
|
|
15
|
+
if (name.startsWith(HELPER_PREFIX)) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
// `.d.ts` 是纯声明,没有运行期实现
|
|
19
|
+
if (name.endsWith('.d.ts')) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
return TESTDATA_EXTENSIONS.includes(extname(name));
|
|
23
|
+
}
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return typeof value === 'object' && value !== null;
|
|
26
|
+
}
|
|
27
|
+
/** 读一个脚本模块的 `dependsOn` 导出(缺省 = 空数组;类型不对直接报错) */
|
|
28
|
+
function readDependsOn(file, loaded) {
|
|
29
|
+
const value = loaded.dependsOn;
|
|
30
|
+
if (value === undefined) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
34
|
+
throw new Error(`[testdata] "${file}" 的 dependsOn 必须是字符串数组(引用其它脚本的去扩展名文件名)`);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 按 `dependsOn` 做**稳定**拓扑排序(Kahn):同层取文件名升序。
|
|
40
|
+
*
|
|
41
|
+
* 三个不变式:
|
|
42
|
+
* - **依赖是权威**:只要 A 声明依赖 B,A 一定排在 B 之后,文件名管不了它。
|
|
43
|
+
* - **同层可复现**:没有依赖关系的脚本按文件名升序,跑多少次都是同一个序。
|
|
44
|
+
* - **坏图直接报错**:未知依赖 / 自依赖 / 循环依赖都在装载期抛错,不猜顺序——
|
|
45
|
+
* 猜错只会变成偶发的外键冲突,比当场失败难查得多。
|
|
46
|
+
*/
|
|
47
|
+
function sortByDependencies(units) {
|
|
48
|
+
const known = new Set(units.map((unit) => unit.name));
|
|
49
|
+
const pending = new Map();
|
|
50
|
+
const dependents = new Map();
|
|
51
|
+
for (const unit of units) {
|
|
52
|
+
const deps = [...new Set(unit.dependsOn ?? [])];
|
|
53
|
+
for (const dep of deps) {
|
|
54
|
+
if (dep === unit.name) {
|
|
55
|
+
throw new Error(`[testdata] "${unit.name}" 依赖了自己:dependsOn 只能引用其它脚本`);
|
|
56
|
+
}
|
|
57
|
+
if (!known.has(dep)) {
|
|
58
|
+
throw new Error(`[testdata] "${unit.name}" 依赖的 "${dep}" 不存在:dependsOn 用去扩展名的文件名`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
pending.set(unit.name, deps.length);
|
|
62
|
+
for (const dep of deps) {
|
|
63
|
+
dependents.set(dep, [...(dependents.get(dep) ?? []), unit.name]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// 候选池保持升序:同层先跑文件名小的
|
|
67
|
+
const ready = units.filter((unit) => pending.get(unit.name) === 0).map((unit) => unit.name).sort();
|
|
68
|
+
const order = [];
|
|
69
|
+
while (ready.length > 0) {
|
|
70
|
+
const name = ready.shift();
|
|
71
|
+
order.push(name);
|
|
72
|
+
for (const next of dependents.get(name) ?? []) {
|
|
73
|
+
const rest = (pending.get(next) ?? 0) - 1;
|
|
74
|
+
pending.set(next, rest);
|
|
75
|
+
if (rest === 0) {
|
|
76
|
+
ready.push(next);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
ready.sort();
|
|
80
|
+
}
|
|
81
|
+
if (order.length !== units.length) {
|
|
82
|
+
const stuck = units.filter((unit) => !order.includes(unit.name)).map((unit) => unit.name);
|
|
83
|
+
throw new Error(`[testdata] 存在循环依赖:${stuck.join(' → ')}`);
|
|
84
|
+
}
|
|
85
|
+
return order.map((name) => units.find((unit) => unit.name === name));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 扫描目录并**装载**全部脚本(`import()` 一次,模块本身由 ESM 缓存复用)。
|
|
89
|
+
*
|
|
90
|
+
* 目录不存在 → 空数组(不是错误:没写测试数据的服务是合法的)。
|
|
91
|
+
* 文件存在但 `load` / `cleanup` 缺一个 → **抛错**,并指出是哪个文件缺哪个方法。
|
|
92
|
+
*
|
|
93
|
+
* 返回的顺序就是**执行序**:`dependsOn` 拓扑序 + 同层文件名升序(`load` 直接正序跑,
|
|
94
|
+
* `cleanup` 直接逆序跑)。
|
|
95
|
+
*/
|
|
96
|
+
export async function discoverTestDataModules(directory = resolveTestDataDirectory()) {
|
|
97
|
+
let entries;
|
|
98
|
+
try {
|
|
99
|
+
entries = (await readdir(directory, { withFileTypes: true }))
|
|
100
|
+
.filter((entry) => entry.isFile())
|
|
101
|
+
.map((entry) => entry.name);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (isRecord(error) && error.code === 'ENOENT') {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
const files = entries.filter(isTestDataFile).sort();
|
|
110
|
+
const units = [];
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
const name = basename(file, extname(file));
|
|
113
|
+
const moduleUrl = pathToFileURL(join(directory, file)).href;
|
|
114
|
+
let loaded;
|
|
115
|
+
try {
|
|
116
|
+
loaded = await import(moduleUrl);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
throw new Error(`[testdata] 装载 "${file}" 失败:${error instanceof Error ? error.message : String(error)}`, {
|
|
120
|
+
cause: error,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (!isRecord(loaded)) {
|
|
124
|
+
throw new Error(`[testdata] "${file}" 没有导出任何东西:必须同时导出 load 与 cleanup`);
|
|
125
|
+
}
|
|
126
|
+
if (typeof loaded.load !== 'function') {
|
|
127
|
+
throw new Error(`[testdata] "${file}" 缺少 load 导出:load 与 cleanup 两个都必须有`);
|
|
128
|
+
}
|
|
129
|
+
if (typeof loaded.cleanup !== 'function') {
|
|
130
|
+
throw new Error(`[testdata] "${file}" 缺少 cleanup 导出:load 与 cleanup 两个都必须有`);
|
|
131
|
+
}
|
|
132
|
+
units.push({
|
|
133
|
+
name,
|
|
134
|
+
dependsOn: readDependsOn(file, loaded),
|
|
135
|
+
load: loaded.load,
|
|
136
|
+
cleanup: loaded.cleanup,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return sortByDependencies(units);
|
|
140
|
+
}
|
|
141
|
+
/** 跑一个脚本步骤(统一日志,失败冒泡给调用方决定) */
|
|
142
|
+
async function runTestDataUnit(params, unit, phase) {
|
|
143
|
+
const fields = { service: params.service ?? '', unit: unit.name, stage: 'testdata', phase };
|
|
144
|
+
params.log?.debug(fields, `[testdata] ${phase} "${unit.name}" start`);
|
|
145
|
+
try {
|
|
146
|
+
await unit[phase]();
|
|
147
|
+
params.log?.debug(fields, `[testdata] ${phase} "${unit.name}" done`);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
params.log?.error({ ...fields, error }, `[testdata] ${phase} "${unit.name}" failed`);
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* 加载测试数据:按**执行序**逐项 `await load()`(依赖在前,同层按文件名升序)。
|
|
156
|
+
*
|
|
157
|
+
* 在 `createTestServer()` 装配完成(`ready()` 之后)自动执行一次;也可以由测试自己再调。
|
|
158
|
+
*/
|
|
159
|
+
export async function loadTestData(params) {
|
|
160
|
+
// 闸门先于一切:非可清理环境下一个脚本都不许跑
|
|
161
|
+
assertCleanableEnvironment(params.env);
|
|
162
|
+
for (const unit of sortByDependencies(params.units ?? [])) {
|
|
163
|
+
await runTestDataUnit(params, unit, 'load');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* 清理测试数据:按执行序**逆序**逐项 `await cleanup()`(后建的先拆)。
|
|
168
|
+
*
|
|
169
|
+
* 依赖不必反向声明:`load` 的拓扑序取逆就是安全的拆除序。
|
|
170
|
+
*
|
|
171
|
+
* 单独调用它的场景很少(多半是要 `resetTestData`):它与 `loadTestData` 成对,
|
|
172
|
+
* 语义是「把脚本插入的数据撤掉」,而不是「把整个库清空」。
|
|
173
|
+
*/
|
|
174
|
+
export async function cleanupTestData(params) {
|
|
175
|
+
assertCleanableEnvironment(params.env);
|
|
176
|
+
for (const unit of sortByDependencies(params.units ?? []).reverse()) {
|
|
177
|
+
await runTestDataUnit(params, unit, 'cleanup');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* 重置测试数据:先**整体** `cleanup`(逆序),再**整体** `load`(正序)。
|
|
182
|
+
*
|
|
183
|
+
* 这是 `beforeEach` 的标准动作(`TestServer.resetTestData()` 的底层实现):
|
|
184
|
+
* 先全部撤掉再全部重建,而不是「逐脚本 cleanup + load」——后者会让前一个脚本重新插入的数据
|
|
185
|
+
* 被后一个脚本的 `cleanup` 顺手删掉(顺序依赖)。分两趟就没有这个问题。
|
|
186
|
+
*
|
|
187
|
+
* ## 两点与「安全」有关的说明
|
|
188
|
+
*
|
|
189
|
+
* - **先在清理阶段**:万一 `load` 失败,库里留下的是「干净」而不是「半套数据 + 半套旧数据」。
|
|
190
|
+
* - **闸门只判一次**(三个入口各自开头),不做逐脚本判定:同一批脚本在同一环境里跑,
|
|
191
|
+
* 逐个判只是把一次判定复制 N 次。
|
|
192
|
+
*/
|
|
193
|
+
export async function resetTestData(params) {
|
|
194
|
+
assertCleanableEnvironment(params.env);
|
|
195
|
+
const units = sortByDependencies(params.units ?? []);
|
|
196
|
+
for (const unit of [...units].reverse()) {
|
|
197
|
+
await runTestDataUnit(params, unit, 'cleanup');
|
|
198
|
+
}
|
|
199
|
+
for (const unit of units) {
|
|
200
|
+
await runTestDataUnit(params, unit, 'load');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testdata.test.d.ts","sourceRoot":"","sources":["../../../source/core/test-support/testdata.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { cleanupTestData, discoverTestDataModules, loadTestData, resetTestData, resolveTestDataDirectory } from './testdata.js';
|
|
6
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
7
|
+
const tempDirs = [];
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
process.env = { ...ORIGINAL_ENV };
|
|
10
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
11
|
+
});
|
|
12
|
+
/** 造一个临时脚本目录(内容按 `文件名 → 模块源码` 写入) */
|
|
13
|
+
async function makeDirectory(files) {
|
|
14
|
+
const dir = await mkdtemp(join(tmpdir(), 'common-testdata-'));
|
|
15
|
+
tempDirs.push(dir);
|
|
16
|
+
await Promise.all(Object.entries(files).map(([name, source]) => writeFile(join(dir, name), source)));
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
/** 造一个记录调用顺序的脚本源码(模块级数组挂在 globalThis 上,跨 import 共享) */
|
|
20
|
+
function scriptSource(name, recorder) {
|
|
21
|
+
return `
|
|
22
|
+
const record = (phase) => globalThis.${recorder}.push(\`\${phase}:${name}\`)
|
|
23
|
+
export function load() { record('load') }
|
|
24
|
+
export function cleanup() { record('cleanup') }
|
|
25
|
+
`;
|
|
26
|
+
}
|
|
27
|
+
describe('discoverTestDataModules', () => {
|
|
28
|
+
it('目录不存在时返回空数组(没写测试数据的服务是合法的)', async () => {
|
|
29
|
+
const dir = join(tmpdir(), `common-testdata-missing-${Date.now()}`);
|
|
30
|
+
await expect(discoverTestDataModules(dir)).resolves.toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
it('按文件名升序装载,name 为去扩展名的文件名', async () => {
|
|
33
|
+
const dir = await makeDirectory({
|
|
34
|
+
'02-second.mjs': scriptSource('second', '__order02'),
|
|
35
|
+
'01-first.mjs': scriptSource('first', '__order01'),
|
|
36
|
+
});
|
|
37
|
+
const units = await discoverTestDataModules(dir);
|
|
38
|
+
expect(units.map((unit) => unit.name)).toEqual(['01-first', '02-second']);
|
|
39
|
+
expect(units.every((unit) => typeof unit.load === 'function' && typeof unit.cleanup === 'function')).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
it('`_` 开头的文件是共享 helper,不参与 load / cleanup', async () => {
|
|
42
|
+
const dir = await makeDirectory({
|
|
43
|
+
'_shared.mjs': 'export const helper = 1',
|
|
44
|
+
'01-real.mjs': scriptSource('real', '__orderShared'),
|
|
45
|
+
});
|
|
46
|
+
const units = await discoverTestDataModules(dir);
|
|
47
|
+
expect(units.map((unit) => unit.name)).toEqual(['01-real']);
|
|
48
|
+
});
|
|
49
|
+
it('非脚本文件(.md / .json / .d.ts)被忽略', async () => {
|
|
50
|
+
const dir = await makeDirectory({
|
|
51
|
+
'README.md': '# 说明',
|
|
52
|
+
'data.json': '{}',
|
|
53
|
+
'types.d.ts': 'export declare function load(): void',
|
|
54
|
+
'01-real.mjs': scriptSource('real', '__orderIgnore'),
|
|
55
|
+
});
|
|
56
|
+
const units = await discoverTestDataModules(dir);
|
|
57
|
+
expect(units.map((unit) => unit.name)).toEqual(['01-real']);
|
|
58
|
+
});
|
|
59
|
+
it('缺 cleanup 导出直接抛错(只写 load 的脚本等于「数据删不掉」)', async () => {
|
|
60
|
+
const dir = await makeDirectory({
|
|
61
|
+
'01-bad.mjs': 'export function load() {}',
|
|
62
|
+
});
|
|
63
|
+
await expect(discoverTestDataModules(dir)).rejects.toThrow(/缺少 cleanup 导出/);
|
|
64
|
+
});
|
|
65
|
+
it('缺 load 导出直接抛错', async () => {
|
|
66
|
+
const dir = await makeDirectory({
|
|
67
|
+
'01-bad.mjs': 'export function cleanup() {}',
|
|
68
|
+
});
|
|
69
|
+
await expect(discoverTestDataModules(dir)).rejects.toThrow(/缺少 load 导出/);
|
|
70
|
+
});
|
|
71
|
+
it('装载期抛错时带上文件名(定位不要靠猜)', async () => {
|
|
72
|
+
const dir = await makeDirectory({
|
|
73
|
+
'01-broken.mjs': 'throw new Error("boom")',
|
|
74
|
+
});
|
|
75
|
+
await expect(discoverTestDataModules(dir)).rejects.toThrow(/装载 "01-broken.mjs" 失败/);
|
|
76
|
+
});
|
|
77
|
+
it('dependsOn 缺省时按文件名升序', async () => {
|
|
78
|
+
const dir = await makeDirectory({
|
|
79
|
+
'02-b.mjs': scriptSource('b', '__orderNoDep'),
|
|
80
|
+
'01-a.mjs': scriptSource('a', '__orderNoDep'),
|
|
81
|
+
});
|
|
82
|
+
await expect(discoverTestDataModules(dir)).resolves.toMatchObject([{ name: '01-a' }, { name: '02-b' }]);
|
|
83
|
+
});
|
|
84
|
+
it('dependsOn 决定顺序:依赖在前,文件名管不了它', async () => {
|
|
85
|
+
const dir = await makeDirectory({
|
|
86
|
+
'01-late.mjs': `export const dependsOn = ['99-early']\n${scriptSource('late', '__orderDep')}`,
|
|
87
|
+
'99-early.mjs': scriptSource('early', '__orderDep'),
|
|
88
|
+
});
|
|
89
|
+
const units = await discoverTestDataModules(dir);
|
|
90
|
+
expect(units.map((unit) => unit.name)).toEqual(['99-early', '01-late']);
|
|
91
|
+
});
|
|
92
|
+
it('同层按文件名升序(无依赖关系的脚本可复现)', async () => {
|
|
93
|
+
const dir = await makeDirectory({
|
|
94
|
+
'03-c.mjs': `export const dependsOn = ['01-base']\n${scriptSource('c', '__orderTie')}`,
|
|
95
|
+
'02-b.mjs': `export const dependsOn = ['01-base']\n${scriptSource('b', '__orderTie')}`,
|
|
96
|
+
'01-base.mjs': scriptSource('base', '__orderTie'),
|
|
97
|
+
});
|
|
98
|
+
const units = await discoverTestDataModules(dir);
|
|
99
|
+
expect(units.map((unit) => unit.name)).toEqual(['01-base', '02-b', '03-c']);
|
|
100
|
+
});
|
|
101
|
+
it('依赖不存在的脚本直接抛错', async () => {
|
|
102
|
+
const dir = await makeDirectory({
|
|
103
|
+
'01-a.mjs': `export const dependsOn = ['99-missing']\n${scriptSource('a', '__orderMissing')}`,
|
|
104
|
+
});
|
|
105
|
+
await expect(discoverTestDataModules(dir)).rejects.toThrow(/依赖的 "99-missing" 不存在/);
|
|
106
|
+
});
|
|
107
|
+
it('依赖自己直接抛错', async () => {
|
|
108
|
+
const dir = await makeDirectory({
|
|
109
|
+
'01-a.mjs': `export const dependsOn = ['01-a']\n${scriptSource('a', '__orderSelf')}`,
|
|
110
|
+
});
|
|
111
|
+
await expect(discoverTestDataModules(dir)).rejects.toThrow(/依赖了自己/);
|
|
112
|
+
});
|
|
113
|
+
it('循环依赖直接抛错(不猜顺序)', async () => {
|
|
114
|
+
const dir = await makeDirectory({
|
|
115
|
+
'01-a.mjs': `export const dependsOn = ['02-b']\n${scriptSource('a', '__orderCycle')}`,
|
|
116
|
+
'02-b.mjs': `export const dependsOn = ['01-a']\n${scriptSource('b', '__orderCycle')}`,
|
|
117
|
+
});
|
|
118
|
+
await expect(discoverTestDataModules(dir)).rejects.toThrow(/循环依赖/);
|
|
119
|
+
});
|
|
120
|
+
it('dependsOn 不是字符串数组直接抛错', async () => {
|
|
121
|
+
const dir = await makeDirectory({
|
|
122
|
+
'01-a.mjs': `export const dependsOn = [1]\n${scriptSource('a', '__orderType')}`,
|
|
123
|
+
});
|
|
124
|
+
await expect(discoverTestDataModules(dir)).rejects.toThrow(/dependsOn 必须是字符串数组/);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
/** 造一批内存脚本单元(不经磁盘,专测执行语义) */
|
|
128
|
+
function unitsOf(order, names) {
|
|
129
|
+
return names.map((name) => ({
|
|
130
|
+
name,
|
|
131
|
+
load: () => {
|
|
132
|
+
order.push(`load:${name}`);
|
|
133
|
+
},
|
|
134
|
+
cleanup: () => {
|
|
135
|
+
order.push(`cleanup:${name}`);
|
|
136
|
+
},
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
describe('testdata 执行语义', () => {
|
|
140
|
+
it('load 正序、cleanup 逆序', async () => {
|
|
141
|
+
const order = [];
|
|
142
|
+
const units = unitsOf(order, ['a', 'b', 'c']);
|
|
143
|
+
await loadTestData({ env: 'test', units });
|
|
144
|
+
expect(order).toEqual(['load:a', 'load:b', 'load:c']);
|
|
145
|
+
order.length = 0;
|
|
146
|
+
await cleanupTestData({ env: 'test', units });
|
|
147
|
+
expect(order).toEqual(['cleanup:c', 'cleanup:b', 'cleanup:a']);
|
|
148
|
+
});
|
|
149
|
+
it('reset 先整体 cleanup(逆序)再整体 load(正序)', async () => {
|
|
150
|
+
const order = [];
|
|
151
|
+
const units = unitsOf(order, ['a', 'b']);
|
|
152
|
+
await resetTestData({ env: 'test', units });
|
|
153
|
+
// 分两趟而不是「逐脚本 cleanup + load」:后者会让前一个脚本刚插入的数据
|
|
154
|
+
// 被后一个脚本的 cleanup 顺手删掉(顺序依赖)。
|
|
155
|
+
expect(order).toEqual(['cleanup:b', 'cleanup:a', 'load:a', 'load:b']);
|
|
156
|
+
});
|
|
157
|
+
it('执行器自己按 dependsOn 定序,不信任调用方传入的顺序', async () => {
|
|
158
|
+
const order = [];
|
|
159
|
+
const units = [
|
|
160
|
+
{ name: '01-late', dependsOn: ['99-early'], load: () => { order.push('load:late'); }, cleanup: () => { order.push('cleanup:late'); } },
|
|
161
|
+
{ name: '99-early', load: () => { order.push('load:early'); }, cleanup: () => { order.push('cleanup:early'); } },
|
|
162
|
+
];
|
|
163
|
+
await loadTestData({ env: 'test', units });
|
|
164
|
+
expect(order).toEqual(['load:early', 'load:late']);
|
|
165
|
+
order.length = 0;
|
|
166
|
+
await cleanupTestData({ env: 'test', units });
|
|
167
|
+
expect(order).toEqual(['cleanup:late', 'cleanup:early']);
|
|
168
|
+
});
|
|
169
|
+
it('没有脚本时三个入口都是空操作(但闸门照样生效)', async () => {
|
|
170
|
+
await expect(loadTestData({ env: 'test' })).resolves.toBeUndefined();
|
|
171
|
+
await expect(cleanupTestData({ env: 'development' })).resolves.toBeUndefined();
|
|
172
|
+
await expect(resetTestData({ env: 'test' })).resolves.toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
it.each([
|
|
175
|
+
['loadTestData', loadTestData],
|
|
176
|
+
['cleanupTestData', cleanupTestData],
|
|
177
|
+
['resetTestData', resetTestData],
|
|
178
|
+
])('%s:脚本失败直接冒泡(不降级成「跳过这条」)', async (_name, run) => {
|
|
179
|
+
const order = [];
|
|
180
|
+
const units = [
|
|
181
|
+
{ name: 'ok', load: () => { order.push('load:ok'); }, cleanup: () => { order.push('cleanup:ok'); } },
|
|
182
|
+
{ name: 'broken', load: () => { throw new Error('insert failed'); }, cleanup: () => { throw new Error('delete failed'); } },
|
|
183
|
+
];
|
|
184
|
+
await expect(run({ env: 'test', units })).rejects.toThrow(/failed/);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
describe('testdata 的环境闸门', () => {
|
|
188
|
+
const units = (order) => unitsOf(order, ['a']);
|
|
189
|
+
it.each([
|
|
190
|
+
['loadTestData', loadTestData],
|
|
191
|
+
['cleanupTestData', cleanupTestData],
|
|
192
|
+
['resetTestData', resetTestData],
|
|
193
|
+
])('%s:非可清理环境一个脚本都不跑', async (_name, run) => {
|
|
194
|
+
const order = [];
|
|
195
|
+
await expect(run({ env: 'production', units: units(order) })).rejects.toThrow(/拒绝在 ENV=/);
|
|
196
|
+
expect(order).toEqual([]);
|
|
197
|
+
});
|
|
198
|
+
it.each(['production', 'staging', ''])('ENV=%s 时拒绝,报错带上当前值', async (env) => {
|
|
199
|
+
await expect(loadTestData({ env })).rejects.toThrow(/ENV="/);
|
|
200
|
+
});
|
|
201
|
+
it.each(['development', 'test'])('ENV=%s 时放行', async (env) => {
|
|
202
|
+
const order = [];
|
|
203
|
+
await loadTestData({ env, units: units(order) });
|
|
204
|
+
expect(order).toEqual(['load:a']);
|
|
205
|
+
});
|
|
206
|
+
it('闸门先于一切:没有脚本也要在非可清理环境下失败', async () => {
|
|
207
|
+
await expect(resetTestData({ env: 'production' })).rejects.toThrow(/ENV="production"/);
|
|
208
|
+
});
|
|
209
|
+
it('判据是传入的 env 参数,不是 process.env(事后改写不影响结论)', async () => {
|
|
210
|
+
const order = [];
|
|
211
|
+
process.env.ENV = 'production';
|
|
212
|
+
await loadTestData({ env: 'test', units: units(order) });
|
|
213
|
+
expect(order).toEqual(['load:a']);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
describe('resolveTestDataDirectory', () => {
|
|
217
|
+
it('默认是 <cwd>/testdata', () => {
|
|
218
|
+
expect(resolveTestDataDirectory()).toBe(join(process.cwd(), 'testdata'));
|
|
219
|
+
});
|
|
220
|
+
});
|