@deepexi/datasense-connector 3.0.1 → 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 (185) hide show
  1. package/README.md +158 -139
  2. package/package.json +19 -28
  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/{runtime/guest/limits.d.ts → src/runtime/guest/limits.ts} +12 -12
  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
  94. package/cjs/index.cjs +0 -1015
  95. package/cjs/runtime.cjs +0 -752
  96. package/cjs/testing.cjs +0 -3591
  97. package/errors.d.ts +0 -17
  98. package/errors.js +0 -53
  99. package/guest.cjs +0 -2659
  100. package/index.d.ts +0 -13
  101. package/index.js +0 -7
  102. package/internal/location.d.ts +0 -6
  103. package/internal/location.js +0 -8
  104. package/presentation.d.ts +0 -38
  105. package/presentation.js +0 -268
  106. package/runtime/guest/bridge.d.ts +0 -53
  107. package/runtime/guest/bridge.js +0 -187
  108. package/runtime/guest/cache.d.ts +0 -3
  109. package/runtime/guest/cache.js +0 -42
  110. package/runtime/guest/crypto.d.ts +0 -13
  111. package/runtime/guest/crypto.js +0 -177
  112. package/runtime/guest/ctx.d.ts +0 -30
  113. package/runtime/guest/ctx.js +0 -124
  114. package/runtime/guest/db.d.ts +0 -6
  115. package/runtime/guest/db.js +0 -59
  116. package/runtime/guest/discover.d.ts +0 -29
  117. package/runtime/guest/discover.js +0 -51
  118. package/runtime/guest/failure.d.ts +0 -24
  119. package/runtime/guest/failure.js +0 -63
  120. package/runtime/guest/http.d.ts +0 -4
  121. package/runtime/guest/http.js +0 -204
  122. package/runtime/guest/index.d.ts +0 -12
  123. package/runtime/guest/index.js +0 -89
  124. package/runtime/guest/limits.js +0 -12
  125. package/runtime/guest/loader.d.ts +0 -15
  126. package/runtime/guest/loader.js +0 -42
  127. package/runtime/guest/log.d.ts +0 -10
  128. package/runtime/guest/log.js +0 -36
  129. package/runtime/guest/map.d.ts +0 -4
  130. package/runtime/guest/map.js +0 -45
  131. package/runtime/guest/paginate.d.ts +0 -6
  132. package/runtime/guest/paginate.js +0 -180
  133. package/runtime/guest/pull.d.ts +0 -12
  134. package/runtime/guest/pull.js +0 -150
  135. package/runtime/guest/push.d.ts +0 -18
  136. package/runtime/guest/push.js +0 -80
  137. package/runtime/guest/signal.d.ts +0 -3
  138. package/runtime/guest/signal.js +0 -12
  139. package/runtime/guest/time.d.ts +0 -5
  140. package/runtime/guest/time.js +0 -127
  141. package/runtime/guest-runtime.d.ts +0 -7
  142. package/runtime/guest-runtime.js +0 -51
  143. package/runtime/index.d.ts +0 -18
  144. package/runtime/index.js +0 -3
  145. package/runtime/protocol.d.ts +0 -276
  146. package/runtime/protocol.js +0 -409
  147. package/runtime/session.d.ts +0 -12
  148. package/runtime/session.js +0 -191
  149. package/t.d.ts +0 -18
  150. package/t.js +0 -26
  151. package/testing/child.cjs +0 -350
  152. package/testing/child.d.ts +0 -1
  153. package/testing/child.js +0 -12
  154. package/testing/compliance.d.ts +0 -41
  155. package/testing/compliance.js +0 -291
  156. package/testing/engine.d.ts +0 -66
  157. package/testing/engine.js +0 -339
  158. package/testing/fixtures.d.ts +0 -145
  159. package/testing/fixtures.js +0 -407
  160. package/testing/gateway.d.ts +0 -65
  161. package/testing/gateway.js +0 -402
  162. package/testing/import-guard.d.ts +0 -57
  163. package/testing/import-guard.js +0 -187
  164. package/testing/index.d.ts +0 -33
  165. package/testing/index.js +0 -10
  166. package/testing/local-db.d.ts +0 -27
  167. package/testing/local-db.js +0 -181
  168. package/testing/pinned-http.d.ts +0 -14
  169. package/testing/pinned-http.js +0 -125
  170. package/testing/recorder.d.ts +0 -15
  171. package/testing/recorder.js +0 -116
  172. package/testing/redaction.d.ts +0 -28
  173. package/testing/redaction.js +0 -67
  174. package/testing/replay.d.ts +0 -50
  175. package/testing/replay.js +0 -313
  176. package/testing/schema.d.ts +0 -13
  177. package/testing/schema.js +0 -65
  178. package/testing/source-loader.d.ts +0 -19
  179. package/testing/source-loader.js +0 -238
  180. package/testing/types.d.ts +0 -79
  181. package/testing/types.js +0 -18
  182. package/types.d.ts +0 -302
  183. package/types.js +0 -13
  184. package/validate.d.ts +0 -78
  185. package/validate.js +0 -597
@@ -0,0 +1,437 @@
1
+ /**
2
+ * 用例回放:guest 的宿主调用一律由用例应答,不联网(`runTests`、CLI `test` 与 runner 的 `fixture_test` 共用)。
3
+ *
4
+ * `createReplayHost(fixture)`:
5
+ * - `http`:匹配键是 `method + URL(query 并入并按键排序、遮蔽规则同录制)+ body`,取 seq 最小的未用往来;
6
+ * 找不到时该调用失败 `FIXTURE_EXCHANGE_MISMATCH`,绝不回落到真实请求;
7
+ * - `db.query`:按 SQL 与参数匹配,一页回全部行;录制时中途失败的查询先回已取到的行,下一次 `db.next` 再报错;
8
+ * - `crypto`:先查录制的往来;没有时 `hash` 本地计算,`hmac` / `sign` 报 `FIXTURE_EXCHANGE_MISMATCH`;
9
+ * - `secret.reveal` 从 `case.secrets` 取值(值是 `***` 也照给),`cache.*` 在单次回放内用内存表。
10
+ *
11
+ * `runFixture(executor, descriptor, fixture)`:每次执行新建一个回放宿主;表结构取连接器描述(静态)。
12
+ * - 拉取用例按描述里该数据流的表执行;全量模式再回放一遍,比较两遍的记录集合(`FULL_READ_NOT_REPEATABLE`);
13
+ * - 推送用例重放 `requests.ndjson`;
14
+ * - 操作用例跑一次操作的 `run`,结果与 `expect.ok`(输出精确相等,不同时给出逐字段差异)或 `expect.error`(失败码)比较;
15
+ * - 建议列用例跑一次 `suggestColumns`,结果与 `expect.columns` 比较。
16
+ * 用例所属的能力不在描述里时失败(`CAPABILITY_UNKNOWN`)。
17
+ * 结果 `ok` 为 true 当且仅当运行没有失败、且除 `FIXTURE_EXCHANGE_UNUSED` 外没有任何 finding。
18
+ */
19
+ import { createHash } from 'node:crypto';
20
+ import type { ConnectorDescriptor, SuggestedColumn, TableDescriptor } from '../descriptor';
21
+ import type { CacheEntry, CryptoRequest, DbQueryResponse, EgressHttpResponse, GuestJob, GuestOperationResult } from '../runtime/protocol';
22
+ import { checkCompliance, type ComplianceFinding } from './compliance';
23
+ import {
24
+ canonicalJson, canonicalRequestUrl, parseFixtures, type ConnectorFixtureFile, type FixtureCase, type FixtureExchange, type LoadedFixture,
25
+ } from './fixtures';
26
+ import { redactSecretValues, redactUrl } from './redaction';
27
+ import type { GuestExecutor, HostMethod, LocalHost, LocalRunResult } from './types';
28
+
29
+ /** 回放宿主自己的宿主调用失败(`code` 回给 guest)。 */
30
+ export class FixtureReplayError extends Error {
31
+ readonly code: string;
32
+
33
+ constructor(code: string, message: string) {
34
+ super(message);
35
+ this.name = 'FixtureReplayError';
36
+ this.code = code;
37
+ }
38
+ }
39
+
40
+ export interface FixtureMismatch { method: string; detail: string }
41
+
42
+ export type ReplayHost = LocalHost & { unused(): FixtureExchange[]; mismatches(): FixtureMismatch[] };
43
+
44
+ /** 一个用例的回放结果。 */
45
+ export interface FixtureCaseResult {
46
+ /** 形如 `streams/orders`。 */
47
+ capability: string;
48
+ name: string;
49
+ kind: FixtureCase['kind'];
50
+ ok: boolean;
51
+ findings: ComplianceFinding[];
52
+ stats: { records: Record<string, number>; removals: Record<string, number>; checkpoints: number; rejected: number };
53
+ error: { code: string; message: string; stack: string | null } | null;
54
+ }
55
+
56
+ export interface RunFixtureOptions {
57
+ /** 每次执行(不是整个用例)的超时,交给 `executor.run`。 */
58
+ timeoutMs?: number;
59
+ }
60
+
61
+ const MISMATCH = 'FIXTURE_EXCHANGE_MISMATCH';
62
+ const PLAIN_HANDLE = /ontos:secret:([A-Za-z_][A-Za-z0-9_]*)/g;
63
+
64
+ function isRecord(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
66
+ }
67
+
68
+ function preview(text: string, max = 200): string {
69
+ return text.length > max ? `${text.slice(0, max)}…` : text;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // 回放宿主
74
+
75
+ export function createReplayHost(fixture: LoadedFixture): ReplayHost {
76
+ const caseSecrets = fixture.case.secrets ?? {};
77
+ const secretValues = Object.values(caseSecrets);
78
+ const redact = (text: string): string => redactSecretValues(text, secretValues);
79
+ const used = new Set<number>();
80
+ const mismatched: FixtureMismatch[] = [];
81
+ const failingCursors = new Map<string, { code: string; message: string }>();
82
+ const cache = new Map<string, CacheEntry>();
83
+
84
+ const mismatch = (method: string, detail: string): never => {
85
+ mismatched.push({ method, detail });
86
+ throw new FixtureReplayError(MISMATCH, `no recorded exchange in fixture "${fixture.name}" matches ${method} ${detail}`);
87
+ };
88
+
89
+ const take = <K extends FixtureExchange['kind']>(kind: K, matches: (exchange: Extract<FixtureExchange, { kind: K }>) => boolean): Extract<FixtureExchange, { kind: K }> | null => {
90
+ for (const exchange of fixture.exchanges) {
91
+ if (exchange.kind !== kind || used.has(exchange.seq)) continue;
92
+ const typed = exchange as Extract<FixtureExchange, { kind: K }>;
93
+ if (!matches(typed)) continue;
94
+ used.add(exchange.seq);
95
+ return typed;
96
+ }
97
+ return null;
98
+ };
99
+
100
+ const lookupSecret = (field: string): string => {
101
+ const value = Object.hasOwn(caseSecrets, field) ? caseSecrets[field] : undefined;
102
+ if (typeof value !== 'string') throw new FixtureReplayError('EGRESS_SECRET_UNAVAILABLE', `Secret '${field}' is not in the fixture's case.json secrets`);
103
+ return value;
104
+ };
105
+
106
+ const http = (payload: unknown): EgressHttpResponse => {
107
+ if (!isRecord(payload) || typeof payload.method !== 'string' || typeof payload.url !== 'string') {
108
+ throw new FixtureReplayError('BAD_REQUEST', 'http request must have a method and a url');
109
+ }
110
+ const query = isRecord(payload.query) ? payload.query as Record<string, string | string[]> : undefined;
111
+ const scriptUrl = canonicalRequestUrl(payload.url, query);
112
+ const url = redactUrl(scriptUrl, secretValues);
113
+ const bodyEncoding = payload.bodyEncoding === 'base64' ? 'base64' : 'utf8';
114
+ const body = typeof payload.body === 'string' ? (bodyEncoding === 'utf8' ? redact(payload.body) : payload.body) : null;
115
+ const method = payload.method;
116
+ const exchange = take('http', (item) =>
117
+ item.request.method === method && item.request.url === url && item.request.body === body && (body === null || item.request.bodyEncoding === bodyEncoding));
118
+ if (!exchange) return mismatch('http', `${method} ${url}${body !== null ? ` with body ${preview(body)}` : ''}`);
119
+ if (exchange.error) throw new FixtureReplayError(exchange.error.code, exchange.error.message);
120
+ if (!exchange.response) throw new FixtureReplayError('HOST_CALL_FAILED', 'the recorded exchange has no response');
121
+ return { status: exchange.response.status, url: scriptUrl, headers: { ...exchange.response.headers }, body: exchange.response.body, bodyEncoding: exchange.response.bodyEncoding };
122
+ };
123
+
124
+ const dbQuery = (payload: unknown): DbQueryResponse => {
125
+ if (!isRecord(payload) || typeof payload.sql !== 'string') throw new FixtureReplayError('BAD_REQUEST', 'db.query needs sql');
126
+ const sql = redact(payload.sql);
127
+ const params = canonicalJson(JSON.parse(redact(JSON.stringify(Array.isArray(payload.params) ? payload.params : []))));
128
+ const exchange = take('db', (item) => item.sql === sql && canonicalJson(item.params) === params);
129
+ if (!exchange) return mismatch('db.query', `${preview(sql.replace(/\s+/g, ' ').trim())} with params ${preview(params)}`);
130
+ const rows = exchange.rows.map((row) => ({ ...row }));
131
+ const fields = exchange.fields.map((field) => ({ ...field }));
132
+ if (!exchange.error) return { cursorId: null, fields, rows, done: true };
133
+ // 查询本身就失败(没有列、没有行):直接报错;取到一部分后失败:先回已取到的行,下一次 db.next 报错。
134
+ if (fields.length === 0 && rows.length === 0) throw new FixtureReplayError(exchange.error.code, exchange.error.message);
135
+ const cursorId = `replay-${exchange.seq}`;
136
+ failingCursors.set(cursorId, exchange.error);
137
+ return { cursorId, fields, rows, done: false };
138
+ };
139
+
140
+ const crypto = (payload: unknown): { value: string } => {
141
+ if (!isRecord(payload) || typeof payload.op !== 'string') throw new FixtureReplayError('BAD_REQUEST', 'crypto request must have an op');
142
+ const request = JSON.parse(redact(JSON.stringify(payload))) as CryptoRequest;
143
+ const key = canonicalJson(request);
144
+ const exchange = take('crypto', (item) => canonicalJson(item.request) === key);
145
+ if (exchange) return { value: exchange.value };
146
+ if (request.op === 'hash') {
147
+ const data = request.data.replace(PLAIN_HANDLE, (_match, field: string) => lookupSecret(field));
148
+ return { value: createHash(request.algorithm).update(data, 'utf8').digest(request.encoding) };
149
+ }
150
+ return mismatch('crypto', `${request.op} ${request.algorithm}`);
151
+ };
152
+
153
+ const call = async (method: HostMethod, payload: unknown): Promise<unknown> => {
154
+ switch (method) {
155
+ case 'http':
156
+ return http(payload);
157
+ case 'db.query':
158
+ return dbQuery(payload);
159
+ case 'db.next': {
160
+ const cursorId = isRecord(payload) && typeof payload.cursorId === 'string' ? payload.cursorId : '';
161
+ const error = failingCursors.get(cursorId);
162
+ if (!error) throw new FixtureReplayError('DB_CURSOR_NOT_FOUND', "The cursor doesn't exist or was closed");
163
+ failingCursors.delete(cursorId);
164
+ throw new FixtureReplayError(error.code, error.message);
165
+ }
166
+ case 'db.close': {
167
+ const cursorId = isRecord(payload) && typeof payload.cursorId === 'string' ? payload.cursorId : '';
168
+ failingCursors.delete(cursorId);
169
+ return null;
170
+ }
171
+ case 'crypto':
172
+ return crypto(payload);
173
+ case 'cache.get': {
174
+ const key = isRecord(payload) && typeof payload.key === 'string' ? payload.key : '';
175
+ const entry = cache.get(key);
176
+ if (!entry || Date.parse(entry.expiresAt) <= Date.now()) return null;
177
+ return entry;
178
+ }
179
+ case 'cache.set': {
180
+ if (!isRecord(payload) || typeof payload.key !== 'string' || typeof payload.value !== 'string' || typeof payload.ttlSeconds !== 'number') {
181
+ throw new FixtureReplayError('BAD_REQUEST', 'cache.set needs key, value and ttlSeconds');
182
+ }
183
+ cache.set(payload.key, { value: payload.value, expiresAt: new Date(Date.now() + payload.ttlSeconds * 1000).toISOString() });
184
+ return null;
185
+ }
186
+ case 'secret.reveal': {
187
+ const field = isRecord(payload) && typeof payload.field === 'string' ? payload.field : '';
188
+ return { value: lookupSecret(field) };
189
+ }
190
+ default:
191
+ throw new FixtureReplayError('HOST_METHOD_UNSUPPORTED', `fixture replay does not answer ${method}`);
192
+ }
193
+ };
194
+
195
+ return {
196
+ call,
197
+ redact,
198
+ async close() {
199
+ failingCursors.clear();
200
+ },
201
+ unused() {
202
+ return fixture.exchanges.filter((exchange) => !used.has(exchange.seq)).map((exchange) => JSON.parse(JSON.stringify(exchange)) as FixtureExchange);
203
+ },
204
+ mismatches() {
205
+ return mismatched.map((item) => ({ ...item }));
206
+ },
207
+ };
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // 回放用例
212
+
213
+ function countBy<T extends { table: string }>(items: readonly T[]): Record<string, number> {
214
+ const counts: Record<string, number> = {};
215
+ for (const item of items) counts[item.table] = (counts[item.table] ?? 0) + 1;
216
+ return counts;
217
+ }
218
+
219
+ function exchangeSummary(exchange: FixtureExchange): string {
220
+ switch (exchange.kind) {
221
+ case 'http':
222
+ return `${exchange.request.method} ${exchange.request.url}`;
223
+ case 'db':
224
+ return preview(exchange.sql.replace(/\s+/g, ' ').trim());
225
+ case 'crypto':
226
+ return `${exchange.request.op} ${exchange.request.algorithm}`;
227
+ }
228
+ }
229
+
230
+ /** 用例范围内的表与消息里的范围说明;能力不在描述里时 tables 为 null。 */
231
+ function scopeOf(descriptor: ConnectorDescriptor, fixtureCase: FixtureCase): { tables: Record<string, TableDescriptor> | null; scope: string } {
232
+ if (fixtureCase.kind === 'pull' || fixtureCase.kind === 'suggest') {
233
+ const stream = Object.hasOwn(descriptor.streams, fixtureCase.stream) ? descriptor.streams[fixtureCase.stream] : undefined;
234
+ return { tables: stream ? stream.tables : null, scope: `stream "${fixtureCase.stream}"` };
235
+ }
236
+ if (fixtureCase.kind === 'push') {
237
+ const push = Object.hasOwn(descriptor.pushes, fixtureCase.push) ? descriptor.pushes[fixtureCase.push] : undefined;
238
+ return { tables: push ? push.tables : null, scope: `push "${fixtureCase.push}"` };
239
+ }
240
+ return { tables: null, scope: `operation "${fixtureCase.operation}"` };
241
+ }
242
+
243
+ function capabilityKnown(descriptor: ConnectorDescriptor, fixtureCase: FixtureCase): boolean {
244
+ switch (fixtureCase.kind) {
245
+ case 'pull':
246
+ case 'suggest':
247
+ return Object.hasOwn(descriptor.streams, fixtureCase.stream);
248
+ case 'push':
249
+ return Object.hasOwn(descriptor.pushes, fixtureCase.push);
250
+ case 'operation':
251
+ return Object.hasOwn(descriptor.operations, fixtureCase.operation);
252
+ }
253
+ }
254
+
255
+ function sameCounts(expected: Record<string, number>, actual: Record<string, number>): string[] {
256
+ return Object.entries(expected)
257
+ .filter(([table, count]) => (actual[table] ?? 0) !== count)
258
+ .map(([table, count]) => `${table}: expected ${count}, got ${actual[table] ?? 0}`);
259
+ }
260
+
261
+ /** 两个 JSON 值的逐字段差异(路径形如 `output.items[0].qty`);相同时为空。 */
262
+ function jsonDiff(expected: unknown, actual: unknown, path: string, out: Array<{ path: string; expected: unknown; actual: unknown }>): void {
263
+ if (isRecord(expected) && isRecord(actual)) {
264
+ for (const key of new Set([...Object.keys(expected), ...Object.keys(actual)])) {
265
+ jsonDiff(expected[key], actual[key], `${path}.${key}`, out);
266
+ }
267
+ return;
268
+ }
269
+ if (Array.isArray(expected) && Array.isArray(actual)) {
270
+ for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
271
+ jsonDiff(expected[index], actual[index], `${path}[${index}]`, out);
272
+ }
273
+ return;
274
+ }
275
+ if (canonicalJson(expected) !== canonicalJson(actual)) out.push({ path, expected: expected ?? null, actual: actual ?? null });
276
+ }
277
+
278
+ function checkOperationExpectation(fixtureCase: Extract<FixtureCase, { kind: 'operation' }>, operation: GuestOperationResult | undefined): ComplianceFinding[] {
279
+ const expect = fixtureCase.expect;
280
+ const failed = (message: string, detail: unknown): ComplianceFinding[] => [{ code: 'EXPECTATION_FAILED', message, detail }];
281
+ if (!operation) return failed('the operation returned no result', null);
282
+ if ('ok' in expect) {
283
+ if (!operation.ok) {
284
+ return failed(`expected the operation to succeed, got ${operation.code}: ${operation.message}`, { expected: 'ok', actual: { code: operation.code, message: operation.message } });
285
+ }
286
+ const actual = 'output' in operation ? operation.output : null;
287
+ const differences: Array<{ path: string; expected: unknown; actual: unknown }> = [];
288
+ jsonDiff(expect.ok, actual, 'output', differences);
289
+ if (differences.length === 0) return [];
290
+ return failed(`expect.ok does not match the output (${differences.map((item) => item.path).join(', ')})`, { expected: expect.ok, actual, differences });
291
+ }
292
+ if (operation.ok) return failed(`expected error ${expect.error}, but the operation succeeded`, { expected: expect.error, actual: 'ok' });
293
+ if (operation.code !== expect.error) {
294
+ return failed(`expected error ${expect.error}, got ${operation.code}: ${operation.message}`, { expected: expect.error, actual: { code: operation.code, message: operation.message } });
295
+ }
296
+ return [];
297
+ }
298
+
299
+ function sortedColumns(columns: readonly SuggestedColumn[]): string[] {
300
+ return columns.map((column) => canonicalJson(column)).sort();
301
+ }
302
+
303
+ function checkSuggestExpectation(fixtureCase: Extract<FixtureCase, { kind: 'suggest' }>, columns: SuggestedColumn[] | undefined): ComplianceFinding[] {
304
+ const actual = columns ?? [];
305
+ const expected = sortedColumns(fixtureCase.expect.columns);
306
+ const got = sortedColumns(actual);
307
+ if (expected.length === got.length && expected.every((item, index) => item === got[index])) return [];
308
+ const expectedNames = fixtureCase.expect.columns.map((column) => column.name).sort();
309
+ const actualNames = actual.map((column) => column.name).sort();
310
+ return [{
311
+ code: 'EXPECTATION_FAILED',
312
+ message: `expect.columns does not match the suggested columns (expected [${expectedNames.join(', ')}], got [${actualNames.join(', ')}])`,
313
+ detail: { expected: fixtureCase.expect.columns, actual },
314
+ }];
315
+ }
316
+
317
+ function checkExpectation(fixture: LoadedFixture, result: LocalRunResult): ComplianceFinding[] {
318
+ if (fixture.case.kind === 'operation') return checkOperationExpectation(fixture.case, result.operation);
319
+ if (fixture.case.kind === 'suggest') return checkSuggestExpectation(fixture.case, result.suggest);
320
+ const expect = fixture.case.expect;
321
+ if (!expect) return [];
322
+ const findings: ComplianceFinding[] = [];
323
+ const failed = (what: string, problems: string[], detail: unknown): void => {
324
+ findings.push({ code: 'EXPECTATION_FAILED', message: `expect.${what} does not match (${problems.join('; ')})`, detail });
325
+ };
326
+ if (expect.records) {
327
+ const actual = countBy(result.records);
328
+ const problems = sameCounts(expect.records, actual);
329
+ if (problems.length > 0) failed('records', problems, { expected: expect.records, actual });
330
+ }
331
+ if (expect.removals) {
332
+ const actual = countBy(result.removals);
333
+ const problems = sameCounts(expect.removals, actual);
334
+ if (problems.length > 0) failed('removals', problems, { expected: expect.removals, actual });
335
+ }
336
+ if (expect.rejected !== undefined && expect.rejected !== result.rejected.length) {
337
+ failed('rejected', [`expected ${expect.rejected}, got ${result.rejected.length}`], { expected: expect.rejected, actual: result.rejected.length });
338
+ }
339
+ if (expect.checkpoints !== undefined && expect.checkpoints !== result.checkpoints.length) {
340
+ failed('checkpoints', [`expected ${expect.checkpoints}, got ${result.checkpoints.length}`], { expected: expect.checkpoints, actual: result.checkpoints.length });
341
+ }
342
+ return findings;
343
+ }
344
+
345
+ /** 回放一个用例(见文件头)。 */
346
+ export async function runFixture(executor: GuestExecutor, descriptor: ConnectorDescriptor, fixture: LoadedFixture, options: RunFixtureOptions = {}): Promise<FixtureCaseResult> {
347
+ const fixtureCase = fixture.case;
348
+ const config = fixtureCase.config;
349
+ const secretFields = Object.keys(fixtureCase.secrets ?? {});
350
+ const base = { capability: fixture.capability, name: fixture.name, kind: fixtureCase.kind };
351
+ const emptyStats = { records: {}, removals: {}, checkpoints: 0, rejected: 0 };
352
+ if (!capabilityKnown(descriptor, fixtureCase)) {
353
+ const message = `${fixture.capability} is not in the connector descriptor`;
354
+ return { ...base, ok: false, findings: [], stats: emptyStats, error: { code: 'CAPABILITY_UNKNOWN', message, stack: null } };
355
+ }
356
+ const { tables, scope } = scopeOf(descriptor, fixtureCase);
357
+
358
+ const execute = async (job: GuestJob, limit?: number): Promise<{ host: ReplayHost; result: LocalRunResult }> => {
359
+ const host = createReplayHost(fixture);
360
+ try {
361
+ const result = await executor.run(job, host, {
362
+ ...(limit !== undefined ? { limit } : {}),
363
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
364
+ });
365
+ return { host, result };
366
+ } finally {
367
+ await host.close();
368
+ }
369
+ };
370
+
371
+ let main: { host: ReplayHost; result: LocalRunResult };
372
+ let rerun: LocalRunResult | undefined;
373
+ switch (fixtureCase.kind) {
374
+ case 'pull': {
375
+ const job: GuestJob = {
376
+ kind: 'pull', config, secretFields, stream: fixtureCase.stream, mode: fixtureCase.mode, state: fixtureCase.state ?? null,
377
+ streamConfig: fixtureCase.streamConfig ?? {}, tables: Object.keys(tables ?? {}),
378
+ };
379
+ main = await execute(job, fixtureCase.limit);
380
+ if (fixtureCase.mode === 'full' && main.result.error === null) rerun = (await execute(job, fixtureCase.limit)).result;
381
+ break;
382
+ }
383
+ case 'push':
384
+ main = await execute({
385
+ kind: 'push', config, secretFields,
386
+ requests: fixture.requests.map((request) => ({ ...request, headers: { ...request.headers }, push: fixtureCase.push })),
387
+ });
388
+ break;
389
+ case 'operation':
390
+ main = await execute({
391
+ kind: 'operation', phase: 'run', config, secretFields,
392
+ operation: fixtureCase.operation, params: fixtureCase.params, idempotencyKey: fixtureCase.idempotencyKey,
393
+ });
394
+ break;
395
+ case 'suggest':
396
+ main = await execute({ kind: 'suggest', config, secretFields, stream: fixtureCase.stream, table: fixtureCase.table });
397
+ break;
398
+ }
399
+
400
+ const result = main.result;
401
+ const error = result.error;
402
+ const findings: ComplianceFinding[] = [];
403
+ for (const item of main.host.mismatches()) {
404
+ findings.push({ code: 'FIXTURE_EXCHANGE_MISMATCH', message: `no recorded exchange matches ${item.method} ${item.detail}`, detail: item });
405
+ }
406
+ if (error === null) {
407
+ // 记录合规只对产出记录的用例有意义;操作与建议列用例只比较期望。未声明字段排在最前(失败原因取第一条)。
408
+ if (fixtureCase.kind === 'pull' || fixtureCase.kind === 'push') {
409
+ findings.unshift(...checkCompliance({
410
+ tables, scope, ...(fixtureCase.kind === 'pull' ? { mode: fixtureCase.mode } : {}), result, ...(rerun ? { rerun } : {}),
411
+ }));
412
+ }
413
+ for (const exchange of main.host.unused()) {
414
+ findings.push({
415
+ code: 'FIXTURE_EXCHANGE_UNUSED',
416
+ message: `recorded exchange ${exchange.seq} (${exchange.kind} ${exchangeSummary(exchange)}) was not used`,
417
+ detail: { seq: exchange.seq, kind: exchange.kind },
418
+ });
419
+ }
420
+ findings.push(...checkExpectation(fixture, result));
421
+ }
422
+
423
+ return {
424
+ ...base,
425
+ ok: error === null && findings.every((finding) => finding.code === 'FIXTURE_EXCHANGE_UNUSED'),
426
+ findings,
427
+ stats: { records: countBy(result.records), removals: countBy(result.removals), checkpoints: result.checkpoints.length, rejected: result.rejected.length },
428
+ error,
429
+ };
430
+ }
431
+
432
+ /** 解析用例文件(格式错误抛 `FixtureFormatError`)后逐个回放,按能力与用例名排序。 */
433
+ export async function runFixtures(executor: GuestExecutor, descriptor: ConnectorDescriptor, files: ConnectorFixtureFile[], options: RunFixtureOptions = {}): Promise<FixtureCaseResult[]> {
434
+ const results: FixtureCaseResult[] = [];
435
+ for (const fixture of parseFixtures(files)) results.push(await runFixture(executor, descriptor, fixture, options));
436
+ return results;
437
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * 跑连接器的测试用例并统计覆盖(`runTests` 给作者与 CLI,`runConnectorTests` 给只有文件的调用方,如 runner 的 `fixture_test`)。
3
+ *
4
+ * 覆盖口径:描述里的每个数据流、推送、操作(能力,形如 `streams/orders`)在它的 `tests/` 下至少有一个用例才算覆盖;
5
+ * 数据流目录下的建议列用例(`"kind": "suggest"`)照常运行,但不计入覆盖。
6
+ * 发布门禁另要求每个连接器操作至少有一个成功用例(`expect.ok`)和一个被拒绝的用例(`expect.error = 'REJECTED'`),
7
+ * 缺的列在 `operationCases`。
8
+ * `ok` 为 true 当且仅当所有运行的用例都通过、没有未覆盖的能力、`operationCases` 为空。
9
+ */
10
+ import type { ConnectorDescriptor } from '../descriptor';
11
+ import { createVmExecutor, type VmExecutorOptions } from './engine';
12
+ import { parseFixtures, type ConnectorFixtureFile, type LoadedFixture } from './fixtures';
13
+ import { runFixture, type FixtureCaseResult } from './replay';
14
+ import type { LocalConnector } from './source-loader';
15
+ import type { GuestExecutor } from './types';
16
+
17
+ export interface TestReport {
18
+ ok: boolean;
19
+ cases: Array<{ capability: string; name: string; ok: boolean; failure: { code: string; message: string } | null }>;
20
+ covered: string[];
21
+ uncovered: string[];
22
+ /** 缺成功用例(`ok`)或拒绝用例(`REJECTED`)的连接器操作;都齐时为 []。 */
23
+ operationCases: OperationCaseGap[];
24
+ }
25
+
26
+ export interface OperationCaseGap { operation: string; missing: Array<'ok' | 'REJECTED'> }
27
+
28
+ export interface RunTestsOptions {
29
+ /**
30
+ * 只跑这些用例:每项是 `<能力>/<用例名>`(如 `streams/orders/first-page`)或能力(如 `streams/orders`,跑它的全部用例)。
31
+ * 覆盖统计不受影响(按全部用例算)。
32
+ */
33
+ cases?: string[];
34
+ /** 每次执行的超时(毫秒)。 */
35
+ timeoutMs?: number;
36
+ }
37
+
38
+ /** 描述里的全部能力,按数据流、推送、操作的顺序。 */
39
+ export function descriptorCapabilities(descriptor: ConnectorDescriptor): string[] {
40
+ return [
41
+ ...Object.keys(descriptor.streams).map((name) => `streams/${name}`),
42
+ ...Object.keys(descriptor.pushes).map((name) => `pushes/${name}`),
43
+ ...Object.keys(descriptor.operations).map((name) => `operations/${name}`),
44
+ ];
45
+ }
46
+
47
+ /** 覆盖统计:有非建议列用例的能力为已覆盖。 */
48
+ export function testCoverage(descriptor: ConnectorDescriptor, fixtures: ReadonlyArray<Pick<LoadedFixture, 'capability' | 'case'>>): { covered: string[]; uncovered: string[] } {
49
+ const tested = new Set(fixtures.filter((fixture) => fixture.case.kind !== 'suggest').map((fixture) => fixture.capability));
50
+ const capabilities = descriptorCapabilities(descriptor);
51
+ return { covered: capabilities.filter((item) => tested.has(item)), uncovered: capabilities.filter((item) => !tested.has(item)) };
52
+ }
53
+
54
+ /** 每个声明的连接器操作缺哪种用例:至少一个 `expect.ok` 和一个 `expect.error = 'REJECTED'`。 */
55
+ export function operationCaseGaps(descriptor: ConnectorDescriptor, fixtures: ReadonlyArray<Pick<LoadedFixture, 'case'>>): OperationCaseGap[] {
56
+ const gaps: OperationCaseGap[] = [];
57
+ for (const operation of Object.keys(descriptor.operations)) {
58
+ const expects = fixtures.flatMap((fixture) => (fixture.case.kind === 'operation' && fixture.case.operation === operation ? [fixture.case.expect] : []));
59
+ const missing: OperationCaseGap['missing'] = [];
60
+ if (!expects.some((expect) => 'ok' in expect)) missing.push('ok');
61
+ if (!expects.some((expect) => 'error' in expect && expect.error === 'REJECTED')) missing.push('REJECTED');
62
+ if (missing.length > 0) gaps.push({ operation, missing });
63
+ }
64
+ return gaps;
65
+ }
66
+
67
+ /** 用例的失败原因:运行错误优先,其次是第一条不是 `FIXTURE_EXCHANGE_UNUSED` 的检查结果。 */
68
+ function failureOf(result: FixtureCaseResult): { code: string; message: string } | null {
69
+ if (result.ok) return null;
70
+ if (result.error) return { code: result.error.code, message: result.error.message };
71
+ const finding = result.findings.find((item) => item.code !== 'FIXTURE_EXCHANGE_UNUSED');
72
+ return finding ? { code: finding.code, message: finding.message } : { code: 'TEST_FAILED', message: 'the test case failed' };
73
+ }
74
+
75
+ function selected(fixture: LoadedFixture, cases: readonly string[] | undefined): boolean {
76
+ if (!cases) return true;
77
+ const id = `${fixture.capability}/${fixture.name}`;
78
+ return cases.some((item) => item === id || item === fixture.capability);
79
+ }
80
+
81
+ /**
82
+ * 解析用例文件(格式错误抛 `FixtureFormatError`),逐个回放,返回报告与每个用例的完整结果。
83
+ * `files` 是能力目录下 `tests/` 里的文件(路径相对源码根目录)。
84
+ */
85
+ export async function runConnectorTests(
86
+ executor: GuestExecutor,
87
+ descriptor: ConnectorDescriptor,
88
+ files: ConnectorFixtureFile[],
89
+ options: RunTestsOptions = {},
90
+ ): Promise<{ report: TestReport; results: FixtureCaseResult[] }> {
91
+ const fixtures = parseFixtures(files);
92
+ const results: FixtureCaseResult[] = [];
93
+ for (const fixture of fixtures) {
94
+ if (!selected(fixture, options.cases)) continue;
95
+ results.push(await runFixture(executor, descriptor, fixture, options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}));
96
+ }
97
+ const { covered, uncovered } = testCoverage(descriptor, fixtures);
98
+ const operationCases = operationCaseGaps(descriptor, fixtures);
99
+ const cases = results.map((result) => ({ capability: result.capability, name: result.name, ok: result.ok, failure: failureOf(result) }));
100
+ const ok = cases.every((item) => item.ok) && uncovered.length === 0 && operationCases.length === 0;
101
+ return { report: { ok, cases, covered, uncovered, operationCases }, results };
102
+ }
103
+
104
+ /**
105
+ * 跑本地连接器的全部测试用例(与平台 runner 同一个 guest 会话,在空环境变量的子进程里执行)。
106
+ * `options.executor` 可换成别的执行器(测试用)。
107
+ */
108
+ export async function runTests(
109
+ connector: LocalConnector,
110
+ options: RunTestsOptions & { executor?: GuestExecutor; vm?: VmExecutorOptions } = {},
111
+ ): Promise<TestReport> {
112
+ const executor = options.executor ?? createVmExecutor(connector, options.vm);
113
+ const { report } = await runConnectorTests(executor, connector.descriptor, connector.testFiles, options);
114
+ return report;
115
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 由记录推断列类型(预览面板的「推断结构」、合规检查共用)。纯函数,不依赖 Node 模块(meta 与 UI 都可能用)。
3
+ */
4
+
5
+ export type InferredColumnType = 'string' | 'number' | 'boolean' | 'timestamp' | 'json' | 'null';
6
+
7
+ /** ISO-8601 日期时间(偏移可省略,省略时按 UTC 解释)。 */
8
+ const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(Z|[+-]\d{2}(?::?\d{2})?)?$/i;
9
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
10
+
11
+ function validDate(year: number, month: number, day: number): boolean {
12
+ return month >= 1 && month <= 12 && day >= 1 && day <= new Date(Date.UTC(year, month, 0)).getUTCDate();
13
+ }
14
+
15
+ /** ISO-8601 日期时间字符串 → 毫秒时刻;不是时返回 null。只有日期的写法不算(推断为 `string`)。 */
16
+ export function parseIsoDateTime(value: string): number | null {
17
+ const match = ISO_DATE_TIME.exec(value);
18
+ if (!match) return null;
19
+ const [, year, month, day, hour, minute, second, offset] = match;
20
+ if (!validDate(Number(year), Number(month), Number(day)) || Number(hour) > 23 || Number(minute) > 59 || Number(second ?? '0') > 59) return null;
21
+ const local = value.slice(0, value.length - (offset?.length ?? 0)).replace(' ', 'T').replace(/(\.\d{3})\d+$/, '$1');
22
+ let zone = 'Z';
23
+ if (offset !== undefined && offset.toUpperCase() !== 'Z') {
24
+ const digits = offset.slice(1).replace(':', '');
25
+ zone = `${offset[0]}${digits.slice(0, 2)}:${digits.length > 2 ? digits.slice(2) : '00'}`;
26
+ }
27
+ const parsed = Date.parse(`${local}${zone}`);
28
+ return Number.isNaN(parsed) ? null : parsed;
29
+ }
30
+
31
+ /** ISO-8601 时间字符串(日期时间,或只有日期)→ 毫秒时刻;断点单调判定用。 */
32
+ export function parseIsoInstant(value: string): number | null {
33
+ const dateTime = parseIsoDateTime(value);
34
+ if (dateTime !== null) return dateTime;
35
+ const match = ISO_DATE.exec(value);
36
+ if (!match || !validDate(Number(match[1]), Number(match[2]), Number(match[3]))) return null;
37
+ return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
38
+ }
39
+
40
+ function kindOf(value: unknown): Exclude<InferredColumnType, 'null'> | null {
41
+ if (value === null || value === undefined) return null;
42
+ if (typeof value === 'number' || typeof value === 'bigint') return 'number';
43
+ if (typeof value === 'boolean') return 'boolean';
44
+ if (typeof value === 'string') return parseIsoDateTime(value) !== null ? 'timestamp' : 'string';
45
+ return 'json';
46
+ }
47
+
48
+ /**
49
+ * 列 → 推断类型:空值不参与;全是空值为 `'null'`;字符串全是 ISO 日期时间为 `'timestamp'`,混有普通字符串为 `'string'`;
50
+ * 对象 / 数组或多种类型混用为 `'json'`。列按首次出现的顺序。
51
+ */
52
+ export function inferRecordSchema(rows: Array<Record<string, unknown>>): Record<string, InferredColumnType> {
53
+ const seen = new Map<string, Set<Exclude<InferredColumnType, 'null'>>>();
54
+ for (const row of rows) {
55
+ for (const [column, value] of Object.entries(row)) {
56
+ const kinds = seen.get(column) ?? new Set();
57
+ const kind = kindOf(value);
58
+ if (kind) kinds.add(kind);
59
+ seen.set(column, kinds);
60
+ }
61
+ }
62
+ const schema: Record<string, InferredColumnType> = {};
63
+ for (const [column, kinds] of seen) {
64
+ if (kinds.size === 0) schema[column] = 'null';
65
+ else if (kinds.size === 1) schema[column] = [...kinds][0] ?? 'json';
66
+ else if (kinds.size === 2 && kinds.has('string') && kinds.has('timestamp')) schema[column] = 'string';
67
+ else schema[column] = 'json';
68
+ }
69
+ return schema;
70
+ }