@deepexi/datasense-connector 0.0.0-stage → 3.1.0

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.
Files changed (93) hide show
  1. package/README.md +191 -2
  2. package/package.json +33 -4
  3. package/scripts/build.ts +188 -0
  4. package/src/define.ts +267 -0
  5. package/src/descriptor.ts +116 -0
  6. package/src/errors.ts +105 -0
  7. package/src/index.ts +108 -0
  8. package/src/internal/location.ts +23 -0
  9. package/src/runtime/guest/bridge.ts +238 -0
  10. package/src/runtime/guest/cache.ts +43 -0
  11. package/src/runtime/guest/check.ts +17 -0
  12. package/src/runtime/guest/crypto.ts +166 -0
  13. package/src/runtime/guest/ctx.ts +157 -0
  14. package/src/runtime/guest/db.ts +59 -0
  15. package/src/runtime/guest/failure.ts +69 -0
  16. package/src/runtime/guest/http.ts +194 -0
  17. package/src/runtime/guest/index.ts +102 -0
  18. package/src/runtime/guest/limits.ts +20 -0
  19. package/src/runtime/guest/loader.ts +210 -0
  20. package/src/runtime/guest/log.ts +47 -0
  21. package/src/runtime/guest/map.ts +45 -0
  22. package/src/runtime/guest/operation.ts +193 -0
  23. package/src/runtime/guest/paginate.ts +169 -0
  24. package/src/runtime/guest/pull.ts +177 -0
  25. package/src/runtime/guest/push.ts +92 -0
  26. package/src/runtime/guest/signal.ts +14 -0
  27. package/src/runtime/guest/suggest.ts +51 -0
  28. package/src/runtime/guest/time.ts +137 -0
  29. package/src/runtime/guest-runtime.ts +75 -0
  30. package/src/runtime/index.ts +53 -0
  31. package/src/runtime/protocol.ts +557 -0
  32. package/src/runtime/session.ts +216 -0
  33. package/src/testing/child.ts +21 -0
  34. package/src/testing/compliance.ts +343 -0
  35. package/src/testing/engine.ts +445 -0
  36. package/src/testing/fixtures.ts +591 -0
  37. package/src/testing/gateway.ts +482 -0
  38. package/src/testing/import-guard.ts +221 -0
  39. package/src/testing/index.ts +43 -0
  40. package/src/testing/local-db.ts +226 -0
  41. package/src/testing/mock-external-system.ts +249 -0
  42. package/src/testing/pinned-http.ts +144 -0
  43. package/src/testing/recorder.ts +140 -0
  44. package/src/testing/redaction.ts +94 -0
  45. package/src/testing/replay.ts +437 -0
  46. package/src/testing/run-tests.ts +115 -0
  47. package/src/testing/schema.ts +70 -0
  48. package/src/testing/source-loader.ts +374 -0
  49. package/src/testing/types.ts +78 -0
  50. package/src/types.ts +329 -0
  51. package/src/validate.ts +267 -0
  52. package/test/build.test.ts +159 -0
  53. package/test/define.test-d.ts +182 -0
  54. package/test/fixtures/acceptance.ts +156 -0
  55. package/test/fixtures/connectors/http-pull/.ontos/descriptor.json +72 -0
  56. package/test/fixtures/connectors/http-pull/.ontos/types.ts +61 -0
  57. package/test/fixtures/connectors/http-pull/package.json +5 -0
  58. package/test/fixtures/connectors/http-pull/streams/items/index.ts +37 -0
  59. package/test/fixtures/connectors/http-pull/streams/items/map.ts +12 -0
  60. package/test/fixtures/connectors/http-pull/streams/items/tests/basic/case.json +15 -0
  61. package/test/fixtures/connectors/http-pull/streams/items/tests/basic/exchanges.ndjson +2 -0
  62. package/test/fixtures/connectors/pg-pull/.ontos/descriptor.json +151 -0
  63. package/test/fixtures/connectors/pg-pull/.ontos/types.ts +62 -0
  64. package/test/fixtures/connectors/pg-pull/lib/sql.ts +14 -0
  65. package/test/fixtures/connectors/pg-pull/package.json +5 -0
  66. package/test/fixtures/connectors/pg-pull/streams/customers/index.ts +30 -0
  67. package/test/fixtures/connectors/pg-pull/streams/customers/tests/customers-full/case.json +15 -0
  68. package/test/fixtures/connectors/pg-pull/streams/customers/tests/customers-full/exchanges.ndjson +3 -0
  69. package/test/fixtures/connectors/pg-pull/streams/orders/index.ts +32 -0
  70. package/test/fixtures/descriptor.ts +54 -0
  71. package/test/fixtures/kingdee-example.ts +82 -0
  72. package/test/fixtures/record-acceptance.ts +32 -0
  73. package/test/runtime/ctx.test.ts +434 -0
  74. package/test/runtime/helpers.ts +157 -0
  75. package/test/runtime/jobs.test.ts +444 -0
  76. package/test/runtime/operation.test.ts +175 -0
  77. package/test/runtime/session.test.ts +229 -0
  78. package/test/testing/acceptance-fixtures.test.ts +69 -0
  79. package/test/testing/compliance.test.ts +247 -0
  80. package/test/testing/engine.test.ts +283 -0
  81. package/test/testing/fixtures.test.ts +221 -0
  82. package/test/testing/gateway.test.ts +337 -0
  83. package/test/testing/import-guard.test.ts +45 -0
  84. package/test/testing/mock-external-system.test.ts +144 -0
  85. package/test/testing/operation-fixtures.test.ts +160 -0
  86. package/test/testing/recorder.test.ts +163 -0
  87. package/test/testing/redaction.test.ts +54 -0
  88. package/test/testing/replay.test.ts +207 -0
  89. package/test/testing/run-tests.test.ts +137 -0
  90. package/test/testing/source-loader.test.ts +220 -0
  91. package/test/validate-config.test.ts +104 -0
  92. package/tsconfig.build.json +23 -0
  93. package/tsconfig.json +19 -0
@@ -0,0 +1,591 @@
1
+ /**
2
+ * 测试用例格式(CLI 录制写、`runTests` 读、runner 服务端回放读,共享值域)。
3
+ *
4
+ * 用例放在能力目录下:`<streams|pushes|operations>/<名>/tests/<用例名>/`:
5
+ * - `case.json`(必有):用例种类与所属能力由所在目录推出,文件里不写 `kind` / `stream` / `push` / `operation`;
6
+ * 唯一例外是数据流目录下的建议列用例,写 `"kind": "suggest"`;
7
+ * - `exchanges.ndjson`(可选):每行一个 `FixtureExchange`,`seq` 严格递增;
8
+ * - `requests.ndjson`(推送用例必有,其他用例不能有):每行一个 `FixtureRequest`。
9
+ *
10
+ * 用例种类:数据流目录下是 `pull`(或 `suggest`),推送目录下是 `push`,操作目录下是 `operation`(回放一次 `run`,`expect` 必填)。
11
+ *
12
+ * 用例名 `^[a-z0-9][a-z0-9_-]{0,63}$`。夹具里长度 ≥ 4 的 Secret 明文在录制时已替换为 `***`,凭证句柄
13
+ * `ontos:secret:<field>` 保持原样(见 `recorder.ts`)。NDJSON 每行以 `\n` 结尾,键按插入顺序输出(便于 diff)。
14
+ *
15
+ * 本文件除 `readFixtureDir`(动态导入 `node:fs`)外不依赖 Node 模块。
16
+ */
17
+ import type { SuggestedColumn } from '../descriptor';
18
+ import type { CryptoRequest } from '../runtime/protocol';
19
+ import { REDACT_MIN_LENGTH, REDACTED } from './redaction';
20
+
21
+ /** 用例目录里的一个文件;`path` 相对源码根目录,如 `streams/orders/tests/basic/case.json`(与 shared 的同名类型同形)。 */
22
+ export interface ConnectorFixtureFile { path: string; content: string }
23
+
24
+ /**
25
+ * 用例的期望(全部可选,给出的每一项都必须精确相等,否则 `EXPECTATION_FAILED`):
26
+ * - `records` / `removals`:表 → 条数(只比较给出的表);
27
+ * - `rejected`:推送拒收条数;`checkpoints`:断点个数。
28
+ */
29
+ export interface FixtureExpectation { records?: Record<string, number>; removals?: Record<string, number>; rejected?: number; checkpoints?: number }
30
+
31
+ /**
32
+ * 连接器操作用例的期望(必填,二选一):`ok` 为 `run` 的输出(按值精确比较),`error` 为失败码(如 `REJECTED`)。
33
+ */
34
+ export type OperationFixtureExpectation = { ok: Record<string, unknown> } | { error: string };
35
+
36
+ /** 建议列用例的期望:`columns` 与建议结果按列名对齐后逐项精确比较(顺序不计)。 */
37
+ export interface SuggestFixtureExpectation { columns: SuggestedColumn[] }
38
+
39
+ /**
40
+ * 解析后的用例(内存形态,`kind` 与能力名由目录推出后补上);`secrets` 是字段 → 值(录制后长度 ≥ 4 的值为 `***`),回放时 `secret.reveal` 照给。
41
+ */
42
+ export type FixtureCase =
43
+ | {
44
+ kind: 'pull'; stream: string; mode: 'full' | 'incremental'; state?: unknown; streamConfig?: Record<string, unknown>;
45
+ config: Record<string, unknown>; secrets?: Record<string, string>; limit?: number; expect?: FixtureExpectation;
46
+ }
47
+ | { kind: 'push'; push: string; config: Record<string, unknown>; secrets?: Record<string, string>; expect?: FixtureExpectation }
48
+ | {
49
+ kind: 'operation'; operation: string; params: Record<string, unknown>; idempotencyKey: string;
50
+ config: Record<string, unknown>; secrets?: Record<string, string>; expect: OperationFixtureExpectation;
51
+ }
52
+ | { kind: 'suggest'; stream: string; table: string; config: Record<string, unknown>; secrets?: Record<string, string>; expect: SuggestFixtureExpectation };
53
+
54
+ /**
55
+ * 一次宿主往来:
56
+ * - `http`:`request.url` 已并入 query 并按键排序、含句柄的 query 值为 `***`;凭证类头为 `***`;body 里的句柄原样保留;
57
+ * - `db`:一次查询的全部分页合成一条,`rows` 为全部行;
58
+ * - `crypto`:请求(key / data 里的句柄原样)与结果。
59
+ */
60
+ export type FixtureExchange =
61
+ | {
62
+ seq: number; kind: 'http';
63
+ request: { method: string; url: string; headers: Record<string, string>; body: string | null; bodyEncoding: 'utf8' | 'base64' };
64
+ response: { status: number; headers: Record<string, string>; body: string; bodyEncoding: 'utf8' | 'base64' } | null;
65
+ error: { code: string; message: string } | null;
66
+ }
67
+ | {
68
+ seq: number; kind: 'db'; sql: string; params: unknown[]; fields: Array<{ name: string; dataTypeId: number }>;
69
+ rows: Array<Record<string, unknown>>; error: { code: string; message: string } | null;
70
+ }
71
+ | { seq: number; kind: 'crypto'; request: CryptoRequest; value: string };
72
+
73
+ /** 推送用例重放的一条收件箱请求。 */
74
+ export interface FixtureRequest { requestId: string; receivedAt: string; headers: Record<string, string>; body: string }
75
+
76
+ /** `capability` 形如 `streams/orders`、`pushes/order_events`、`operations/create_followup`。 */
77
+ export interface LoadedFixture { capability: string; name: string; case: FixtureCase; exchanges: FixtureExchange[]; requests: FixtureRequest[] }
78
+
79
+ /** 夹具格式错误:`file` 为相对源码根目录的路径,`line` 为 NDJSON 的行号(从 1 起;与整个文件相关时为 null)。 */
80
+ export class FixtureFormatError extends Error {
81
+ readonly file: string;
82
+ readonly line: number | null;
83
+
84
+ constructor(message: string, file: string, line: number | null = null) {
85
+ super(line === null ? `${file}: ${message}` : `${file}:${line}: ${message}`);
86
+ this.name = 'FixtureFormatError';
87
+ this.file = file;
88
+ this.line = line;
89
+ }
90
+ }
91
+
92
+ export const FIXTURE_CASE_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
93
+ /** 能力目录下放用例的子目录名。 */
94
+ export const TESTS_DIR = 'tests';
95
+
96
+ type CapabilityGroup = 'streams' | 'pushes' | 'operations';
97
+ const CAPABILITY_GROUPS: readonly CapabilityGroup[] = ['streams', 'pushes', 'operations'];
98
+ const CAPABILITY_NAME_PATTERN: Record<CapabilityGroup, RegExp> = {
99
+ streams: /^[a-z][a-z0-9_]{0,27}$/,
100
+ pushes: /^[a-z][a-z0-9_]{0,27}$/,
101
+ operations: /^[a-z][a-z0-9_]{0,62}$/,
102
+ };
103
+ const COLUMN_TYPES: readonly string[] = ['string', 'integer', 'decimal', 'boolean', 'date', 'instant', 'json'];
104
+
105
+ const CASE_FILE = 'case.json';
106
+ const EXCHANGES_FILE = 'exchanges.ndjson';
107
+ const REQUESTS_FILE = 'requests.ndjson';
108
+ const CASE_FILES: ReadonlySet<string> = new Set([CASE_FILE, EXCHANGES_FILE, REQUESTS_FILE]);
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // 结构校验
112
+
113
+ /** 校验失败:消息交给调用方补上文件与行号。 */
114
+ class ShapeError extends Error {}
115
+
116
+ function isRecord(value: unknown): value is Record<string, unknown> {
117
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
118
+ }
119
+
120
+ function fail(message: string): never {
121
+ throw new ShapeError(message);
122
+ }
123
+
124
+ function onlyKeys(value: Record<string, unknown>, allowed: readonly string[], where: string): void {
125
+ for (const key of Object.keys(value)) {
126
+ if (!allowed.includes(key)) fail(`${where} has an unknown field "${key}"`);
127
+ }
128
+ }
129
+
130
+ function requireString(value: unknown, where: string): string {
131
+ if (typeof value !== 'string') fail(`${where} must be a string`);
132
+ return value;
133
+ }
134
+
135
+ function requireNonEmptyString(value: unknown, where: string): string {
136
+ const text = requireString(value, where);
137
+ if (text === '') fail(`${where} must not be empty`);
138
+ return text;
139
+ }
140
+
141
+ function requireRecord(value: unknown, where: string): Record<string, unknown> {
142
+ if (!isRecord(value)) fail(`${where} must be an object`);
143
+ return value;
144
+ }
145
+
146
+ function requireStringRecord(value: unknown, where: string): Record<string, string> {
147
+ const record = requireRecord(value, where);
148
+ for (const [key, item] of Object.entries(record)) {
149
+ if (typeof item !== 'string') fail(`${where}.${key} must be a string`);
150
+ }
151
+ return record as Record<string, string>;
152
+ }
153
+
154
+ function requireCount(value: unknown, where: string): number {
155
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) fail(`${where} must be a non-negative integer`);
156
+ return value;
157
+ }
158
+
159
+ function requireError(value: unknown, where: string): void {
160
+ if (value === null) return;
161
+ const error = requireRecord(value, where);
162
+ onlyKeys(error, ['code', 'message'], where);
163
+ requireString(error.code, `${where}.code`);
164
+ requireString(error.message, `${where}.message`);
165
+ }
166
+
167
+ function requireEncoding(value: unknown, where: string): void {
168
+ if (value !== 'utf8' && value !== 'base64') fail(`${where} must be "utf8" or "base64"`);
169
+ }
170
+
171
+ function validateExpectation(value: unknown): void {
172
+ const expectation = requireRecord(value, 'expect');
173
+ onlyKeys(expectation, ['records', 'removals', 'rejected', 'checkpoints'], 'expect');
174
+ for (const field of ['records', 'removals'] as const) {
175
+ if (expectation[field] === undefined) continue;
176
+ for (const [table, count] of Object.entries(requireRecord(expectation[field], `expect.${field}`))) requireCount(count, `expect.${field}.${table}`);
177
+ }
178
+ if (expectation.rejected !== undefined) requireCount(expectation.rejected, 'expect.rejected');
179
+ if (expectation.checkpoints !== undefined) requireCount(expectation.checkpoints, 'expect.checkpoints');
180
+ }
181
+
182
+ function validateOperationExpectation(value: unknown): void {
183
+ const expectation = requireRecord(value, 'expect');
184
+ onlyKeys(expectation, ['ok', 'error'], 'expect');
185
+ if ((expectation.ok === undefined) === (expectation.error === undefined)) fail('expect must have exactly one of "ok" and "error"');
186
+ if (expectation.ok !== undefined) requireRecord(expectation.ok, 'expect.ok');
187
+ else requireNonEmptyString(expectation.error, 'expect.error');
188
+ }
189
+
190
+ function validateSuggestExpectation(value: unknown): void {
191
+ const expectation = requireRecord(value, 'expect');
192
+ onlyKeys(expectation, ['columns'], 'expect');
193
+ if (!Array.isArray(expectation.columns)) fail('expect.columns must be an array of columns');
194
+ expectation.columns.forEach((item: unknown, i) => {
195
+ const where = `expect.columns[${i}]`;
196
+ const column = requireRecord(item, where);
197
+ onlyKeys(column, ['name', 'type', 'precision', 'scale', 'title', 'description'], where);
198
+ requireNonEmptyString(column.name, `${where}.name`);
199
+ if (typeof column.type !== 'string' || !COLUMN_TYPES.includes(column.type)) fail(`${where}.type must be a column type`);
200
+ requireString(column.title, `${where}.title`);
201
+ if (column.description !== undefined) requireString(column.description, `${where}.description`);
202
+ for (const key of ['precision', 'scale'] as const) {
203
+ if (column[key] !== undefined) requireCount(column[key], `${where}.${key}`);
204
+ }
205
+ });
206
+ }
207
+
208
+ /** 校验 `case.json`(文件形态:不写 `kind` 与能力名,建议列用例写 `"kind": "suggest"`),返回补上 `kind` 与能力名的用例。 */
209
+ function validateCase(value: unknown, group: CapabilityGroup, capabilityName: string): FixtureCase {
210
+ const item = requireRecord(value, 'case');
211
+ const common = ['config', 'secrets', 'expect'];
212
+ const finish = (): void => {
213
+ requireRecord(item.config, 'config');
214
+ if (item.secrets !== undefined) requireStringRecord(item.secrets, 'secrets');
215
+ };
216
+ if (group === 'operations') {
217
+ if (item.kind !== undefined) fail('case.json under operations/ must not set "kind" (it follows from the directory)');
218
+ onlyKeys(item, [...common, 'params', 'idempotencyKey'], 'case');
219
+ requireRecord(item.params, 'params');
220
+ requireNonEmptyString(item.idempotencyKey, 'idempotencyKey');
221
+ finish();
222
+ if (item.expect === undefined) fail('operation cases need expect: { ok } or { error }');
223
+ validateOperationExpectation(item.expect);
224
+ return { ...item, kind: 'operation', operation: capabilityName } as unknown as FixtureCase;
225
+ }
226
+ if (group === 'pushes') {
227
+ if (item.kind !== undefined) fail('case.json under pushes/ must not set "kind" (it follows from the directory)');
228
+ onlyKeys(item, common, 'case');
229
+ finish();
230
+ if (item.expect !== undefined) validateExpectation(item.expect);
231
+ return { ...item, kind: 'push', push: capabilityName } as unknown as FixtureCase;
232
+ }
233
+ if (item.kind === 'suggest') {
234
+ onlyKeys(item, ['kind', ...common, 'table'], 'case');
235
+ requireNonEmptyString(item.table, 'table');
236
+ finish();
237
+ if (item.expect === undefined) fail('suggest cases need expect: { columns }');
238
+ validateSuggestExpectation(item.expect);
239
+ return { ...item, kind: 'suggest', stream: capabilityName } as unknown as FixtureCase;
240
+ }
241
+ if (item.kind !== undefined) fail('case.json under streams/ may only set "kind": "suggest" (other kinds follow from the directory)');
242
+ onlyKeys(item, [...common, 'mode', 'state', 'streamConfig', 'limit'], 'case');
243
+ if (item.mode !== 'full' && item.mode !== 'incremental') fail('mode must be "full" or "incremental"');
244
+ if (item.streamConfig !== undefined) requireRecord(item.streamConfig, 'streamConfig');
245
+ if (item.limit !== undefined && (typeof item.limit !== 'number' || !Number.isSafeInteger(item.limit) || item.limit <= 0)) fail('limit must be a positive integer');
246
+ finish();
247
+ if (item.expect !== undefined) validateExpectation(item.expect);
248
+ return { ...item, kind: 'pull', stream: capabilityName } as unknown as FixtureCase;
249
+ }
250
+
251
+ function validateCryptoRequest(value: unknown): void {
252
+ const request = requireRecord(value, 'request');
253
+ requireString(request.data, 'request.data');
254
+ if (request.encoding !== 'hex' && request.encoding !== 'base64') fail('request.encoding must be "hex" or "base64"');
255
+ switch (request.op) {
256
+ case 'hash':
257
+ onlyKeys(request, ['op', 'algorithm', 'data', 'encoding'], 'request');
258
+ if (!['md5', 'sha1', 'sha256', 'sha512'].includes(String(request.algorithm))) fail('request.algorithm is not a hash algorithm');
259
+ return;
260
+ case 'hmac':
261
+ onlyKeys(request, ['op', 'algorithm', 'key', 'data', 'encoding'], 'request');
262
+ requireString(request.key, 'request.key');
263
+ if (!['sha1', 'sha256', 'sha512'].includes(String(request.algorithm))) fail('request.algorithm is not an hmac algorithm');
264
+ return;
265
+ case 'sign':
266
+ onlyKeys(request, ['op', 'algorithm', 'key', 'data', 'encoding'], 'request');
267
+ requireString(request.key, 'request.key');
268
+ if (request.algorithm !== 'rsa-sha256') fail('request.algorithm must be "rsa-sha256"');
269
+ return;
270
+ default:
271
+ fail('request.op must be one of "hmac", "sign", "hash"');
272
+ }
273
+ }
274
+
275
+ function validateExchange(value: unknown): FixtureExchange {
276
+ const item = requireRecord(value, 'exchange');
277
+ if (typeof item.seq !== 'number' || !Number.isSafeInteger(item.seq) || item.seq <= 0) fail('seq must be a positive integer');
278
+ switch (item.kind) {
279
+ case 'http': {
280
+ onlyKeys(item, ['seq', 'kind', 'request', 'response', 'error'], 'exchange');
281
+ const request = requireRecord(item.request, 'request');
282
+ onlyKeys(request, ['method', 'url', 'headers', 'body', 'bodyEncoding'], 'request');
283
+ requireNonEmptyString(request.method, 'request.method');
284
+ requireNonEmptyString(request.url, 'request.url');
285
+ requireStringRecord(request.headers, 'request.headers');
286
+ if (request.body !== null) requireString(request.body, 'request.body');
287
+ requireEncoding(request.bodyEncoding, 'request.bodyEncoding');
288
+ if (item.response !== null) {
289
+ const response = requireRecord(item.response, 'response');
290
+ onlyKeys(response, ['status', 'headers', 'body', 'bodyEncoding'], 'response');
291
+ if (typeof response.status !== 'number' || !Number.isInteger(response.status)) fail('response.status must be an integer');
292
+ requireStringRecord(response.headers, 'response.headers');
293
+ requireString(response.body, 'response.body');
294
+ requireEncoding(response.bodyEncoding, 'response.bodyEncoding');
295
+ }
296
+ requireError(item.error, 'error');
297
+ if ((item.response === null) === (item.error === null)) fail('exactly one of response and error must be null');
298
+ break;
299
+ }
300
+ case 'db': {
301
+ onlyKeys(item, ['seq', 'kind', 'sql', 'params', 'fields', 'rows', 'error'], 'exchange');
302
+ requireString(item.sql, 'sql');
303
+ if (!Array.isArray(item.params)) fail('params must be an array');
304
+ if (!Array.isArray(item.fields)) fail('fields must be an array');
305
+ for (const field of item.fields) {
306
+ const entry = requireRecord(field, 'fields[]');
307
+ requireString(entry.name, 'fields[].name');
308
+ if (typeof entry.dataTypeId !== 'number' || !Number.isInteger(entry.dataTypeId)) fail('fields[].dataTypeId must be an integer');
309
+ }
310
+ if (!Array.isArray(item.rows) || !item.rows.every(isRecord)) fail('rows must be an array of objects');
311
+ requireError(item.error, 'error');
312
+ break;
313
+ }
314
+ case 'crypto':
315
+ onlyKeys(item, ['seq', 'kind', 'request', 'value'], 'exchange');
316
+ validateCryptoRequest(item.request);
317
+ requireString(item.value, 'value');
318
+ break;
319
+ default:
320
+ fail('kind must be one of "http", "db", "crypto"');
321
+ }
322
+ return item as unknown as FixtureExchange;
323
+ }
324
+
325
+ function validateRequest(value: unknown): FixtureRequest {
326
+ const item = requireRecord(value, 'request');
327
+ onlyKeys(item, ['requestId', 'receivedAt', 'headers', 'body'], 'request');
328
+ requireNonEmptyString(item.requestId, 'requestId');
329
+ requireNonEmptyString(item.receivedAt, 'receivedAt');
330
+ requireStringRecord(item.headers, 'headers');
331
+ requireString(item.body, 'body');
332
+ return item as unknown as FixtureRequest;
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // 解析
337
+
338
+ function parseJsonFile<T>(file: ConnectorFixtureFile, validate: (value: unknown) => T): T {
339
+ let value: unknown;
340
+ try {
341
+ value = JSON.parse(file.content);
342
+ } catch (error) {
343
+ throw new FixtureFormatError(`not valid JSON (${error instanceof Error ? error.message : String(error)})`, file.path);
344
+ }
345
+ try {
346
+ return validate(value);
347
+ } catch (error) {
348
+ if (error instanceof ShapeError) throw new FixtureFormatError(error.message, file.path);
349
+ throw error;
350
+ }
351
+ }
352
+
353
+ /** NDJSON:逐行解析,空行跳过(行号照算)。 */
354
+ function parseNdjson<T>(file: ConnectorFixtureFile, validate: (value: unknown, line: number) => T): T[] {
355
+ const items: T[] = [];
356
+ const lines = file.content.split('\n');
357
+ for (let index = 0; index < lines.length; index += 1) {
358
+ const text = (lines[index] ?? '').replace(/\r$/, '');
359
+ if (text.trim() === '') continue;
360
+ const line = index + 1;
361
+ let value: unknown;
362
+ try {
363
+ value = JSON.parse(text);
364
+ } catch {
365
+ throw new FixtureFormatError('not valid JSON', file.path, line);
366
+ }
367
+ try {
368
+ items.push(validate(value, line));
369
+ } catch (error) {
370
+ if (error instanceof ShapeError) throw new FixtureFormatError(error.message, file.path, line);
371
+ throw error;
372
+ }
373
+ }
374
+ return items;
375
+ }
376
+
377
+ function normalizePath(filePath: string): string {
378
+ return filePath.replace(/\\/g, '/').replace(/^\.\//, '');
379
+ }
380
+
381
+ /** 用例所在能力目录的分组与名字。 */
382
+ function capabilityOfCase(fixtureCase: FixtureCase): { group: CapabilityGroup; name: string } {
383
+ switch (fixtureCase.kind) {
384
+ case 'pull':
385
+ case 'suggest':
386
+ return { group: 'streams', name: fixtureCase.stream };
387
+ case 'push':
388
+ return { group: 'pushes', name: fixtureCase.push };
389
+ case 'operation':
390
+ return { group: 'operations', name: fixtureCase.operation };
391
+ }
392
+ }
393
+
394
+ /**
395
+ * 校验并解析用例文件(路径形如 `<streams|pushes|operations>/<名>/tests/<用例名>/<文件>`,其余路径报错);
396
+ * 结果按能力、用例名排序。格式错误抛 `FixtureFormatError`(含 file、line)。以 `.` 开头的文件(如 `.DS_Store`)忽略。
397
+ */
398
+ export function parseFixtures(files: ConnectorFixtureFile[]): LoadedFixture[] {
399
+ const groups = new Map<string, { group: CapabilityGroup; capabilityName: string; name: string; dir: string; files: Map<string, ConnectorFixtureFile> }>();
400
+ for (const raw of files) {
401
+ const file = { path: normalizePath(raw.path), content: raw.content };
402
+ const parts = file.path.split('/');
403
+ if (parts.some((part) => part === '' || part === '.' || part === '..')) throw new FixtureFormatError('test case paths must be plain relative paths', file.path);
404
+ if (parts.some((part) => part.startsWith('.'))) continue;
405
+ const [group, capabilityName = '', tests, name = '', fileName = ''] = parts;
406
+ if (parts.length !== 5 || !CAPABILITY_GROUPS.includes(group as CapabilityGroup) || tests !== TESTS_DIR) {
407
+ throw new FixtureFormatError(`unexpected file: test cases live at <streams|pushes|operations>/<name>/${TESTS_DIR}/<case>/{${[...CASE_FILES].join(',')}}`, file.path);
408
+ }
409
+ const capabilityGroup = group as CapabilityGroup;
410
+ if (!CAPABILITY_NAME_PATTERN[capabilityGroup].test(capabilityName)) {
411
+ throw new FixtureFormatError(`${capabilityGroup} name "${capabilityName}" must match ${CAPABILITY_NAME_PATTERN[capabilityGroup].source}`, file.path);
412
+ }
413
+ if (!FIXTURE_CASE_NAME_PATTERN.test(name)) {
414
+ throw new FixtureFormatError(`case name "${name}" must match ${FIXTURE_CASE_NAME_PATTERN.source}`, file.path);
415
+ }
416
+ if (!CASE_FILES.has(fileName)) {
417
+ throw new FixtureFormatError(`unexpected file in case "${name}": expected ${[...CASE_FILES].join(', ')}`, file.path);
418
+ }
419
+ const dir = parts.slice(0, 4).join('/');
420
+ const entry = groups.get(dir) ?? { group: capabilityGroup, capabilityName, name, dir, files: new Map<string, ConnectorFixtureFile>() };
421
+ if (entry.files.has(fileName)) throw new FixtureFormatError('duplicate file', file.path);
422
+ entry.files.set(fileName, file);
423
+ groups.set(dir, entry);
424
+ }
425
+
426
+ const fixtures: LoadedFixture[] = [];
427
+ for (const dir of [...groups.keys()].sort()) {
428
+ const entry = groups.get(dir);
429
+ if (!entry) continue;
430
+ const { name } = entry;
431
+ const casePath = `${dir}/${CASE_FILE}`;
432
+ const caseFile = entry.files.get(CASE_FILE);
433
+ if (!caseFile) throw new FixtureFormatError(`case "${name}" has no ${CASE_FILE}`, casePath);
434
+ const fixtureCase = parseJsonFile(caseFile, (value) => validateCase(value, entry.group, entry.capabilityName));
435
+
436
+ const exchangesFile = entry.files.get(EXCHANGES_FILE);
437
+ let previousSeq = 0;
438
+ const exchanges = exchangesFile
439
+ ? parseNdjson(exchangesFile, (value) => {
440
+ const exchange = validateExchange(value);
441
+ if (exchange.seq <= previousSeq) fail(`seq ${exchange.seq} must be greater than the previous seq ${previousSeq}`);
442
+ previousSeq = exchange.seq;
443
+ return exchange;
444
+ })
445
+ : [];
446
+
447
+ const requestsFile = entry.files.get(REQUESTS_FILE);
448
+ if (fixtureCase.kind === 'push' && !requestsFile) {
449
+ throw new FixtureFormatError(`push case "${name}" needs ${REQUESTS_FILE} next to ${CASE_FILE}`, casePath);
450
+ }
451
+ if (fixtureCase.kind !== 'push' && requestsFile) {
452
+ throw new FixtureFormatError(`${REQUESTS_FILE} only belongs to push cases`, requestsFile.path);
453
+ }
454
+ const seen = new Set<string>();
455
+ const requests = requestsFile
456
+ ? parseNdjson(requestsFile, (value) => {
457
+ const request = validateRequest(value);
458
+ if (seen.has(request.requestId)) fail(`requestId "${request.requestId}" appears more than once`);
459
+ seen.add(request.requestId);
460
+ return request;
461
+ })
462
+ : [];
463
+
464
+ fixtures.push({ capability: `${entry.group}/${entry.capabilityName}`, name, case: fixtureCase, exchanges, requests });
465
+ }
466
+ return fixtures;
467
+ }
468
+
469
+ // ---------------------------------------------------------------------------
470
+ // 序列化
471
+
472
+ /** `case.secrets` 写盘前:长度 ≥ 4 的值换成 `***`(与遮蔽规则一致,更短的值不替换)。 */
473
+ function redactSecrets(secrets: Record<string, string>): Record<string, string> {
474
+ return Object.fromEntries(Object.entries(secrets).map(([field, value]) => [field, value.length >= REDACT_MIN_LENGTH ? REDACTED : value]));
475
+ }
476
+
477
+ /** 内存形态 → `case.json` 的文件形态:去掉由目录推出的 `kind` 与能力名(建议列用例保留 `"kind": "suggest"`)。 */
478
+ function caseFileContent(fixtureCase: FixtureCase): Record<string, unknown> {
479
+ const { kind, ...rest } = fixtureCase as FixtureCase & Record<string, unknown>;
480
+ const body: Record<string, unknown> = { ...rest };
481
+ delete body.stream;
482
+ delete body.push;
483
+ delete body.operation;
484
+ if (body.secrets !== undefined) body.secrets = redactSecrets(body.secrets as Record<string, string>);
485
+ return kind === 'suggest' ? { kind, ...body } : body;
486
+ }
487
+
488
+ function ndjson(items: readonly unknown[]): string {
489
+ return items.map((item) => `${JSON.stringify(item)}\n`).join('');
490
+ }
491
+
492
+ /**
493
+ * 把一个用例写成用例文件(目录由 `case.kind` 与能力名决定):`case.json`(两格缩进)、`exchanges.ndjson`(有往来时)、
494
+ * `requests.ndjson`(推送用例)。`case.secrets` 里长度 ≥ 4 的值写成 `***`。与 `parseFixtures` 往返无损(`secrets` 已遮蔽时)。
495
+ */
496
+ export function serializeFixture(
497
+ name: string,
498
+ input: { case: FixtureCase; exchanges: FixtureExchange[]; requests?: FixtureRequest[] },
499
+ ): ConnectorFixtureFile[] {
500
+ const capability = capabilityOfCase(input.case);
501
+ const dir = `${capability.group}/${capability.name}/${TESTS_DIR}/${name}`;
502
+ if (!CAPABILITY_NAME_PATTERN[capability.group].test(capability.name)) {
503
+ throw new FixtureFormatError(`${capability.group} name "${capability.name}" must match ${CAPABILITY_NAME_PATTERN[capability.group].source}`, `${dir}/${CASE_FILE}`);
504
+ }
505
+ if (!FIXTURE_CASE_NAME_PATTERN.test(name)) throw new FixtureFormatError(`case name "${name}" must match ${FIXTURE_CASE_NAME_PATTERN.source}`, `${dir}/${CASE_FILE}`);
506
+ const requests = input.requests ?? [];
507
+ if (input.case.kind !== 'push' && requests.length > 0) throw new FixtureFormatError(`${REQUESTS_FILE} only belongs to push cases`, `${dir}/${REQUESTS_FILE}`);
508
+ const files: ConnectorFixtureFile[] = [{ path: `${dir}/${CASE_FILE}`, content: `${JSON.stringify(caseFileContent(input.case), null, 2)}\n` }];
509
+ if (input.exchanges.length > 0) files.push({ path: `${dir}/${EXCHANGES_FILE}`, content: ndjson(input.exchanges) });
510
+ if (input.case.kind === 'push') files.push({ path: `${dir}/${REQUESTS_FILE}`, content: ndjson(requests) });
511
+ return files;
512
+ }
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // 读目录(Node)
516
+
517
+ /**
518
+ * 读 `<dir>/<streams|pushes|operations>/<名>/tests/**` 下的全部文件(`path` 相对 `dir`、用 `/` 分隔、按路径排序);没有时返回空数组。
519
+ * 以 `.` 开头的文件与目录跳过;遇到符号链接抛 `FixtureFormatError`(用例是租户数据,不跟随链接)。
520
+ */
521
+ export async function readFixtureDir(dir: string): Promise<ConnectorFixtureFile[]> {
522
+ const fs = await import('node:fs/promises');
523
+ const path = await import('node:path');
524
+ const files: ConnectorFixtureFile[] = [];
525
+
526
+ const walk = async (absolute: string, relative: string): Promise<void> => {
527
+ const entries = await fs.readdir(absolute, { withFileTypes: true });
528
+ for (const entry of entries) {
529
+ if (entry.name.startsWith('.')) continue;
530
+ const childAbsolute = path.join(absolute, entry.name);
531
+ const childRelative = `${relative}/${entry.name}`;
532
+ if (entry.isSymbolicLink()) throw new FixtureFormatError('symbolic links are not allowed in test cases', childRelative);
533
+ if (entry.isDirectory()) await walk(childAbsolute, childRelative);
534
+ else if (entry.isFile()) files.push({ path: childRelative, content: await fs.readFile(childAbsolute, 'utf8') });
535
+ }
536
+ };
537
+
538
+ const listDirectories = async (absolute: string): Promise<string[]> => {
539
+ try {
540
+ const entries = await fs.readdir(absolute, { withFileTypes: true });
541
+ return entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')).map((entry) => entry.name);
542
+ } catch (error) {
543
+ if (isRecord(error) && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return [];
544
+ throw error;
545
+ }
546
+ };
547
+
548
+ for (const group of CAPABILITY_GROUPS) {
549
+ for (const name of await listDirectories(path.join(dir, group))) {
550
+ const relative = `${group}/${name}/${TESTS_DIR}`;
551
+ const absolute = path.join(dir, group, name, TESTS_DIR);
552
+ let stat: Awaited<ReturnType<typeof fs.lstat>>;
553
+ try {
554
+ stat = await fs.lstat(absolute);
555
+ } catch (error) {
556
+ if (isRecord(error) && error.code === 'ENOENT') continue;
557
+ throw error;
558
+ }
559
+ if (stat.isSymbolicLink()) throw new FixtureFormatError('symbolic links are not allowed in test cases', relative);
560
+ if (!stat.isDirectory()) throw new FixtureFormatError('must be a directory', relative);
561
+ await walk(absolute, relative);
562
+ }
563
+ }
564
+ return files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
565
+ }
566
+
567
+ // ---------------------------------------------------------------------------
568
+ // 录制与回放共用的规整
569
+
570
+ /** http 请求的规范 URL:query 并入 URL,再按键排序(同名键保持相对顺序)。解析不了时原样返回。 */
571
+ export function canonicalRequestUrl(url: string, query: Record<string, string | string[]> | undefined): string {
572
+ let parsed: URL;
573
+ try {
574
+ parsed = new URL(url);
575
+ } catch {
576
+ return url;
577
+ }
578
+ for (const [name, value] of Object.entries(query ?? {})) {
579
+ for (const item of Array.isArray(value) ? value : [value]) parsed.searchParams.append(name, item);
580
+ }
581
+ parsed.searchParams.sort();
582
+ return parsed.toString();
583
+ }
584
+
585
+ /** 键排序后的 JSON(db 参数、crypto 请求的比较用)。 */
586
+ export function canonicalJson(value: unknown): string {
587
+ return JSON.stringify(value, (_key, item: unknown) => {
588
+ if (!isRecord(item)) return item;
589
+ return Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]]));
590
+ });
591
+ }