@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,283 @@
1
+ /**
2
+ * 本地执行器:vm 子进程与进程内执行的结果一致、与 runner 的 guest 会话一致;limit 截断;超时杀子进程;子进程环境变量为空。
3
+ */
4
+ import { afterAll, describe, expect, spyOn, test } from 'bun:test';
5
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import ts from 'typescript';
9
+ import { defineStream, type ConnectorDescriptor } from '../../src';
10
+ import type { GuestJob, GuestPullBatch } from '../../src/runtime';
11
+ import {
12
+ createInProcessExecutor, createLocalGateway, createVmExecutor, loadLocalConnector, runStream, type LocalRunResult,
13
+ } from '../../src/testing';
14
+ import { VM_CHILD_ENTRY } from '../../src/testing/engine';
15
+ import { HTTP_PULL_DIR } from '../fixtures/acceptance';
16
+ import { descriptor, push, stream, table } from '../fixtures/descriptor';
17
+ import { runGuest } from '../runtime/helpers';
18
+
19
+ const CONFIG = { baseUrl: 'https://api.example.com/v1' };
20
+ const SECRETS = { token: 'tok-secret-1' };
21
+
22
+ const PAGES: Record<string, { items: Array<{ id: string; name: string; updatedAt: string }>; nextCursor: string | null }> = {
23
+ first: {
24
+ items: [
25
+ { id: 'a', name: 'Alpha', updatedAt: '2026-10-01T00:00:00.000Z' },
26
+ { id: 'b', name: 'Beta', updatedAt: '2026-10-02T00:00:00.000Z' },
27
+ ],
28
+ nextCursor: 'p2',
29
+ },
30
+ p2: { items: [{ id: 'c', name: 'Gamma', updatedAt: '2026-10-03T00:00:00.000Z' }], nextCursor: null },
31
+ };
32
+
33
+ /** 假上游:没有 cursor 回第一页,`cursor=p2` 回第二页;记下收到的鉴权头。 */
34
+ function twoPageFetch(seen: string[]): typeof fetch {
35
+ const impl = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
36
+ const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url);
37
+ seen.push(new Headers(init?.headers).get('authorization') ?? '');
38
+ return Response.json(PAGES[url.searchParams.get('cursor') ?? 'first']);
39
+ };
40
+ return impl as typeof fetch;
41
+ }
42
+
43
+ function gateway(seen: string[] = []) {
44
+ return createLocalGateway({ hosts: ['api.example.com'], secrets: SECRETS, fetch: twoPageFetch(seen) });
45
+ }
46
+
47
+ const EXPECTED_RECORDS = [
48
+ { table: 'items', row: { id: 'a', name: 'Alpha', updated_at: '2026-10-01T00:00:00.000Z' } },
49
+ { table: 'items', row: { id: 'b', name: 'Beta', updated_at: '2026-10-02T00:00:00.000Z' } },
50
+ { table: 'items', row: { id: 'c', name: 'Gamma', updated_at: '2026-10-03T00:00:00.000Z' } },
51
+ ];
52
+ const EXPECTED_STATES = [{ updatedSince: '2026-10-02T00:00:00.000Z', cursor: 'p2' }, { updatedSince: '2026-10-03T00:00:00.000Z' }];
53
+
54
+ const pullJob = (overrides: Partial<Extract<GuestJob, { kind: 'pull' }>> = {}): GuestJob => ({
55
+ kind: 'pull', config: CONFIG, secretFields: ['token'], stream: 'items', mode: 'full', state: null, streamConfig: {}, tables: ['items'], ...overrides,
56
+ });
57
+
58
+ const normalizeLogs = (result: LocalRunResult): LocalRunResult => ({ ...result, logs: result.logs.map((log) => ({ ...log, at: '<at>' })) });
59
+
60
+ const temps: string[] = [];
61
+
62
+ async function tempDir(prefix: string): Promise<string> {
63
+ const dir = await mkdtemp(path.join(os.tmpdir(), prefix));
64
+ temps.push(dir);
65
+ return dir;
66
+ }
67
+
68
+ const PUSH_DESCRIPTOR = (name: string, tables: ConnectorDescriptor['pushes'][string]['tables']): ConnectorDescriptor =>
69
+ descriptor({ name, version: '0.0.1', pushes: { default: push(tables) } });
70
+
71
+ /** 写一个只有默认推送的连接器项目(`pushes/default/index.ts` 为 `source`,描述手写进 `.ontos/descriptor.json`)。 */
72
+ async function pushProject(name: string, tables: ConnectorDescriptor['pushes'][string]['tables'], source: string): Promise<string> {
73
+ const dir = await tempDir(`sdk-engine-${name}-`);
74
+ await mkdir(path.join(dir, '.ontos'));
75
+ await mkdir(path.join(dir, 'pushes', 'default'), { recursive: true });
76
+ await writeFile(path.join(dir, '.ontos', 'descriptor.json'), JSON.stringify(PUSH_DESCRIPTOR(name, tables)));
77
+ await writeFile(path.join(dir, 'pushes', 'default', 'index.ts'), source);
78
+ await writeFile(path.join(dir, 'package.json'), JSON.stringify({ dependencies: { '@deepexi/datasense-connector': '^3.0.0' } }));
79
+ return dir;
80
+ }
81
+
82
+ const SPIN_SOURCE = `import { definePush } from '@deepexi/datasense-connector';
83
+ export default definePush('default', { map() { for (;;) { /* 死循环,等超时 */ } } });
84
+ `;
85
+
86
+ const ESCAPE_SOURCE = `import { definePush } from '@deepexi/datasense-connector';
87
+
88
+ declare const process: { env: Record<string, string> } | undefined;
89
+ declare const Bun: unknown;
90
+
91
+ /** 每个请求体是一个探测名,map 记录探测结果,供测试断言沙箱里拿不到这些能力。 */
92
+ export default definePush('default', {
93
+ map(ctx: { record(table: string, row: Record<string, unknown>): void }, req: { body: string }) {
94
+ const probes: Record<string, () => unknown> = {
95
+ process: () => typeof process === 'undefined' ? 'absent' : JSON.stringify(process.env),
96
+ require: () => typeof (globalThis as { require?: unknown }).require === 'undefined' ? 'absent' : 'present',
97
+ bun: () => typeof Bun === 'undefined' ? 'absent' : 'present',
98
+ };
99
+ ctx.record('probe', { name: req.body, result: String(probes[req.body]!()) });
100
+ },
101
+ });
102
+ `;
103
+
104
+ const spinProject = (name: string) => pushProject(name, {}, SPIN_SOURCE);
105
+ const escapeProject = () => pushProject('escape', { probe: table({ name: 'string', result: 'string' }, ['name']) }, ESCAPE_SOURCE);
106
+
107
+ /** 替身子进程入口:先把自己的 pid 与环境变量名写进文件,再加载真正的子进程入口。 */
108
+ async function probeChildEntry(): Promise<{ entry: string; report: () => Promise<{ pid: number; envKeys: string[] }> }> {
109
+ const dir = await tempDir('sdk-engine-probe-');
110
+ const reportFile = path.join(dir, 'report.json');
111
+ const entry = path.join(dir, 'probe-child.ts');
112
+ await writeFile(entry, [
113
+ "import { writeFileSync } from 'node:fs';",
114
+ `writeFileSync(${JSON.stringify(reportFile)}, JSON.stringify({ pid: process.pid, envKeys: Object.keys(process.env) }));`,
115
+ `await import(${JSON.stringify(VM_CHILD_ENTRY)});`,
116
+ ].join('\n'));
117
+ return { entry, report: async () => JSON.parse(await readFile(reportFile, 'utf8')) as { pid: number; envKeys: string[] } };
118
+ }
119
+
120
+ function isAlive(pid: number): boolean {
121
+ try {
122
+ process.kill(pid, 0);
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ afterAll(async () => {
130
+ await Promise.all(temps.map((dir) => rm(dir, { recursive: true, force: true })));
131
+ });
132
+
133
+ describe('the HTTP acceptance connector', () => {
134
+ test('vm execution matches the runner guest session (records, checkpoints, substituted credentials)', async () => {
135
+ const connector = await loadLocalConnector(HTTP_PULL_DIR, { typescript: ts });
136
+ expect(connector.name).toBe('http_pull');
137
+ const seen: string[] = [];
138
+ const order: string[] = [];
139
+ const inner = gateway(seen);
140
+ const host = {
141
+ call(method: Parameters<typeof inner.call>[0], payload: unknown) {
142
+ order.push(method);
143
+ return inner.call(method, payload);
144
+ },
145
+ close: () => inner.close(),
146
+ };
147
+ const vm = await createVmExecutor(connector).run(pullJob(), host);
148
+ expect(vm.error).toBeNull();
149
+ expect(vm.records).toEqual(EXPECTED_RECORDS);
150
+ expect(vm.checkpoints.map((checkpoint) => checkpoint.state)).toEqual(EXPECTED_STATES);
151
+ expect(vm.checkpoints.map((checkpoint) => checkpoint.seq)).toEqual([1, 2]);
152
+ expect(vm.logs.map((log) => log.message)).toEqual(['page 1: 2 item(s), next cursor p2', 'page 2: 1 item(s), next cursor none']);
153
+ expect(seen).toEqual(['Bearer tok-secret-1', 'Bearer tok-secret-1']);
154
+ // 不再有 definition / declaration:宿主只收到出站调用
155
+ expect(order).toEqual(['http', 'http']);
156
+
157
+ // runner 路径:同一份打包串交给 runner 用的 guest 会话
158
+ const runnerHost = gateway();
159
+ const runner = await runGuest({
160
+ code: connector.code,
161
+ job: pullJob(),
162
+ expect: { name: connector.name, version: connector.version },
163
+ host: { http: (payload) => runnerHost.call('http', payload), emit: () => null },
164
+ });
165
+ expect(runner.outcome.type).toBe('result');
166
+ const batches = runner.calls.filter((call) => call.method === 'emit').map((call) => call.payload as GuestPullBatch);
167
+ expect(batches.flatMap((batch) => batch.records)).toEqual(vm.records);
168
+ expect(batches.flatMap((batch) => (batch.checkpoint ? [batch.checkpoint.state] : []))).toEqual(vm.checkpoints.map((checkpoint) => checkpoint.state));
169
+ });
170
+
171
+ test('runStream in process gives the same result as the vm, field by field', async () => {
172
+ const connector = await loadLocalConnector(HTTP_PULL_DIR, { typescript: ts });
173
+ const items = (await import(path.join(HTTP_PULL_DIR, 'streams/items/index.ts')) as { default: unknown }).default;
174
+ const local = await runStream({ descriptor: connector.descriptor, implementations: { streams: { items } } }, { stream: 'items', config: CONFIG, host: gateway() });
175
+ const vm = await createVmExecutor(connector).run(pullJob(), gateway());
176
+ expect(local.error).toBeNull();
177
+ expect(local.records).toEqual(EXPECTED_RECORDS);
178
+ expect(local.stopped).toBe(false);
179
+ expect(normalizeLogs(local)).toEqual(normalizeLogs(vm));
180
+ });
181
+
182
+ test('a pull rejects records for tables outside the stream with SCHEMA_ERROR', async () => {
183
+ const module = {
184
+ descriptor: descriptor({ name: 'stray', streams: { items: stream({ items: table({ id: 'string' }, ['id']) }) } }),
185
+ implementations: { streams: { items: defineStream('items', async function* (ctx) { yield ctx.record('other' as never, { id: 'x' } as never); }) } },
186
+ };
187
+ const result = await runStream(module, { stream: 'items', config: {}, host: createLocalGateway({ hosts: [], secrets: {} }) });
188
+ expect(result.records).toEqual([]);
189
+ expect(result.error?.code).toBe('SCHEMA_ERROR');
190
+ });
191
+ });
192
+
193
+ describe('limit', () => {
194
+ const TEN = {
195
+ descriptor: descriptor({ name: 'ten', streams: { items: stream({ item: table({ id: 'integer' }, ['id']) }, { syncModes: ['full'] }) } }),
196
+ implementations: {
197
+ streams: {
198
+ items: defineStream('items', async function* (ctx) {
199
+ for (let id = 0; id < 10; id += 1) {
200
+ yield ctx.record('item', { id });
201
+ if (id % 4 === 3) yield ctx.checkpoint({ after: id });
202
+ }
203
+ yield ctx.checkpoint({ after: 'end' });
204
+ }),
205
+ },
206
+ },
207
+ };
208
+
209
+ test('limit 3 of 10 records: 3 records and stopped', async () => {
210
+ const result = await runStream(TEN, { stream: 'items', config: {}, limit: 3, host: createLocalGateway({ hosts: [], secrets: {} }) });
211
+ expect(result.error).toBeNull();
212
+ expect(result.records.map((record) => record.row.id)).toEqual([0, 1, 2]);
213
+ expect(result.stopped).toBe(true);
214
+ // 断点跨过了被截掉的记录,不记
215
+ expect(result.checkpoints).toEqual([]);
216
+ });
217
+
218
+ test('without a limit every record and checkpoint is kept', async () => {
219
+ const result = await createInProcessExecutor(TEN).run(
220
+ { kind: 'pull', config: {}, secretFields: [], stream: 'items', mode: 'full', state: null, streamConfig: {}, tables: ['item'] },
221
+ createLocalGateway({ hosts: [], secrets: {} }),
222
+ );
223
+ expect(result.records).toHaveLength(10);
224
+ expect(result.stopped).toBe(false);
225
+ expect(result.checkpoints.map((checkpoint) => checkpoint.state)).toEqual([{ after: 3 }, { after: 7 }, { after: 'end' }]);
226
+ });
227
+ });
228
+
229
+ describe('vm child process', () => {
230
+ const pushJob = (bodies: string[]): GuestJob => ({
231
+ kind: 'push', config: {}, secretFields: [],
232
+ requests: bodies.map((body, index) => ({
233
+ requestId: `r${index}`, push: 'default', receivedAt: '2026-10-25T00:00:00.000Z', headers: { 'content-type': 'text/plain' }, body,
234
+ })),
235
+ });
236
+
237
+ test('an async infinite loop ends with TIMEOUT within 2-5 seconds and the child is gone; the child env is empty', async () => {
238
+ const source = await loadLocalConnector(await spinProject('spin'), { typescript: ts });
239
+ const probe = await probeChildEntry();
240
+ const startedAt = Date.now();
241
+ const result = await createVmExecutor(source, { childEntry: probe.entry }).run(pushJob(['x']), createLocalGateway({ hosts: [], secrets: {} }), { timeoutMs: 2000 });
242
+ const elapsed = Date.now() - startedAt;
243
+ expect(result.error?.code).toBe('TIMEOUT');
244
+ expect(elapsed).toBeGreaterThanOrEqual(2000);
245
+ expect(elapsed).toBeLessThan(5000);
246
+ const report = await probe.report();
247
+ expect(isAlive(report.pid)).toBe(false);
248
+ expect(report.envKeys).toEqual([]);
249
+ }, 10_000);
250
+
251
+ test('connector code in the vm cannot reach process, require or Bun', async () => {
252
+ const source = await loadLocalConnector(await escapeProject(), { typescript: ts });
253
+ const result = await createVmExecutor(source).run(pushJob(['process', 'require', 'bun']), createLocalGateway({ hosts: [], secrets: {} }));
254
+ expect(result.error).toBeNull();
255
+ expect(result.records.map((record) => record.row)).toEqual([
256
+ { name: 'process', result: 'absent' },
257
+ { name: 'require', result: 'absent' },
258
+ { name: 'bun', result: 'absent' },
259
+ ]);
260
+ });
261
+
262
+ test('whatever the child writes to stdout goes to the parent stderr, not stdout', async () => {
263
+ const source = await loadLocalConnector(await spinProject('stdout'), { typescript: ts });
264
+ const dir = await tempDir('sdk-engine-stdout-');
265
+ const entry = path.join(dir, 'noisy-child.ts');
266
+ await writeFile(entry, [
267
+ "process.stdout.write('noise-from-child\\n');",
268
+ `await import(${JSON.stringify(VM_CHILD_ENTRY)});`,
269
+ ].join('\n'));
270
+ const stdout: string[] = [];
271
+ const stderr: string[] = [];
272
+ const outSpy = spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { stdout.push(String(chunk)); return true; }) as typeof process.stdout.write);
273
+ const errSpy = spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { stderr.push(String(chunk)); return true; }) as typeof process.stderr.write);
274
+ try {
275
+ await createVmExecutor(source, { childEntry: entry }).run(pushJob(['x']), createLocalGateway({ hosts: [], secrets: {} }), { timeoutMs: 1500 });
276
+ } finally {
277
+ outSpy.mockRestore();
278
+ errSpy.mockRestore();
279
+ }
280
+ expect(stderr.join('')).toContain('noise-from-child');
281
+ expect(stdout.join('')).not.toContain('noise-from-child');
282
+ }, 10_000);
283
+ });
@@ -0,0 +1,221 @@
1
+ /**
2
+ * 用例格式:位置 `<能力目录>/tests/<用例名>/`、kind 由目录推出、用例名、必有文件、NDJSON 行号、往返无损、读目录。
3
+ */
4
+ import { afterAll, describe, expect, test } from 'bun:test';
5
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import {
9
+ COMPLIANCE_CODES, FixtureFormatError, parseFixtures, readFixtureDir, serializeFixture,
10
+ type FixtureCase, type FixtureExchange, type FixtureRequest,
11
+ } from '../../src/testing';
12
+
13
+ const PULL_CASE = JSON.stringify({ mode: 'full', config: {} });
14
+ const T = 'streams/items/tests';
15
+
16
+ function formatError(run: () => unknown): FixtureFormatError {
17
+ try {
18
+ run();
19
+ } catch (error) {
20
+ if (error instanceof FixtureFormatError) return error;
21
+ throw error;
22
+ }
23
+ throw new Error('expected a FixtureFormatError');
24
+ }
25
+
26
+ describe('parseFixtures', () => {
27
+ test('case names must be lower case: Basic fails, basic-1 passes; kind and the stream come from the directory', () => {
28
+ const error = formatError(() => parseFixtures([{ path: `${T}/Basic/case.json`, content: PULL_CASE }]));
29
+ expect(error.file).toBe(`${T}/Basic/case.json`);
30
+ expect(error.line).toBeNull();
31
+ const [fixture] = parseFixtures([{ path: `${T}/basic-1/case.json`, content: PULL_CASE }]);
32
+ expect(fixture?.name).toBe('basic-1');
33
+ expect(fixture?.capability).toBe('streams/items');
34
+ expect(fixture?.case).toEqual({ kind: 'pull', stream: 'items', mode: 'full', config: {} });
35
+ expect(fixture?.exchanges).toEqual([]);
36
+ expect(fixture?.requests).toEqual([]);
37
+ });
38
+
39
+ test('a push case without requests.ndjson fails, pointing at its case.json', () => {
40
+ const error = formatError(() => parseFixtures([{ path: 'pushes/default/tests/hook/case.json', content: JSON.stringify({ config: {} }) }]));
41
+ expect(error.file).toBe('pushes/default/tests/hook/case.json');
42
+ expect(error.message).toContain('requests.ndjson');
43
+ });
44
+
45
+ test('line 3 of exchanges.ndjson that is not JSON fails with line 3', () => {
46
+ const exchange = (seq: number) => JSON.stringify({ seq, kind: 'crypto', request: { op: 'hash', algorithm: 'sha256', data: 'x', encoding: 'hex' }, value: 'v' });
47
+ const error = formatError(() => parseFixtures([
48
+ { path: `${T}/basic/case.json`, content: PULL_CASE },
49
+ { path: `${T}/basic/exchanges.ndjson`, content: `${exchange(1)}\n${exchange(2)}\n{not json\n` },
50
+ ]));
51
+ expect(error.file).toBe(`${T}/basic/exchanges.ndjson`);
52
+ expect(error.line).toBe(3);
53
+ });
54
+
55
+ test('a missing case.json, an unknown file, a bad shape, and seq going backwards all fail', () => {
56
+ expect(formatError(() => parseFixtures([{ path: `${T}/basic/exchanges.ndjson`, content: '' }])).file).toBe(`${T}/basic/case.json`);
57
+ expect(formatError(() => parseFixtures([{ path: `${T}/basic/notes.txt`, content: '' }])).file).toBe(`${T}/basic/notes.txt`);
58
+ expect(formatError(() => parseFixtures([{ path: 'src/index.ts', content: '' }])).file).toBe('src/index.ts');
59
+ expect(formatError(() => parseFixtures([{ path: `${T}/basic/case.json`, content: JSON.stringify({ config: {} }) }])).message)
60
+ .toContain('mode');
61
+ expect(formatError(() => parseFixtures([{ path: `${T}/basic/case.json`, content: JSON.stringify({ mode: 'full', config: {}, extra: 1 }) }])).message)
62
+ .toContain('extra');
63
+ // 旧格式:kind / stream 写在 case.json 里、或放在根目录 fixtures/ 下
64
+ expect(formatError(() => parseFixtures([{ path: `${T}/basic/case.json`, content: JSON.stringify({ kind: 'pull', mode: 'full', config: {} }) }])).message)
65
+ .toContain('kind');
66
+ expect(formatError(() => parseFixtures([{ path: `${T}/basic/case.json`, content: JSON.stringify({ stream: 'items', mode: 'full', config: {} }) }])).message)
67
+ .toContain('stream');
68
+ expect(formatError(() => parseFixtures([{ path: 'fixtures/basic/case.json', content: PULL_CASE }])).file).toBe('fixtures/basic/case.json');
69
+ expect(formatError(() => parseFixtures([{ path: 'operations/create_followup/tests/ok/case.json', content: JSON.stringify({ kind: 'operation', params: {}, idempotencyKey: 'k', config: {}, expect: { ok: {} } }) }])).message)
70
+ .toContain('kind');
71
+ const hash = (seq: number) => JSON.stringify({ seq, kind: 'crypto', request: { op: 'hash', algorithm: 'sha256', data: 'x', encoding: 'hex' }, value: 'v' });
72
+ const backwards = formatError(() => parseFixtures([
73
+ { path: `${T}/basic/case.json`, content: PULL_CASE },
74
+ { path: `${T}/basic/exchanges.ndjson`, content: `${hash(2)}\n${hash(1)}\n` },
75
+ ]));
76
+ expect(backwards.line).toBe(2);
77
+ const requests = formatError(() => parseFixtures([
78
+ { path: `${T}/basic/case.json`, content: PULL_CASE },
79
+ { path: `${T}/basic/requests.ndjson`, content: '' },
80
+ ]));
81
+ expect(requests.file).toBe(`${T}/basic/requests.ndjson`);
82
+ });
83
+
84
+ test('a suggest case under a stream directory writes kind suggest and needs expect.columns', () => {
85
+ const [fixture] = parseFixtures([{
86
+ path: 'streams/customers/tests/s1/case.json',
87
+ content: JSON.stringify({ kind: 'suggest', table: 'customers', config: {}, expect: { columns: [{ name: 'vip', type: 'string', title: 'VIP' }] } }),
88
+ }]);
89
+ expect(fixture?.case).toMatchObject({ kind: 'suggest', stream: 'customers', table: 'customers' });
90
+ expect(formatError(() => parseFixtures([{ path: 'streams/customers/tests/s1/case.json', content: JSON.stringify({ kind: 'suggest', table: 'customers', config: {} }) }])).message)
91
+ .toContain('columns');
92
+ expect(formatError(() => parseFixtures([{ path: 'pushes/default/tests/s1/case.json', content: JSON.stringify({ kind: 'suggest', table: 'customers', config: {}, expect: { columns: [] } }) }])).message)
93
+ .toContain('kind');
94
+ });
95
+
96
+ test('dot files are ignored and cases come back sorted by name', () => {
97
+ const fixtures = parseFixtures([
98
+ { path: `${T}/zeta/case.json`, content: PULL_CASE },
99
+ { path: `${T}/.DS_Store`, content: '' },
100
+ { path: `${T}/alpha/case.json`, content: PULL_CASE },
101
+ ]);
102
+ expect(fixtures.map((fixture) => fixture.name)).toEqual(['alpha', 'zeta']);
103
+ });
104
+ });
105
+
106
+ describe('serializeFixture', () => {
107
+ const exchanges: FixtureExchange[] = [
108
+ {
109
+ seq: 1, kind: 'http',
110
+ request: { method: 'POST', url: 'https://api.example.com/v1/items?a=1&b=2', headers: { authorization: '***' }, body: '{"q":"ontos:secret:token"}', bodyEncoding: 'utf8' },
111
+ response: { status: 200, headers: { 'content-type': 'application/json' }, body: '{"ok":true}', bodyEncoding: 'utf8' },
112
+ error: null,
113
+ },
114
+ { seq: 2, kind: 'http', request: { method: 'GET', url: 'https://api.example.com/v1/x', headers: {}, body: null, bodyEncoding: 'utf8' }, response: null, error: { code: 'EGRESS_TIMEOUT', message: 'timed out' } },
115
+ { seq: 3, kind: 'db', sql: 'select 1 as n', params: [1, 'a', null, { k: [1] }], fields: [{ name: 'n', dataTypeId: 23 }], rows: [{ n: 1 }], error: null },
116
+ { seq: 5, kind: 'crypto', request: { op: 'hmac', algorithm: 'sha256', key: 'ontos:secret:token', data: 'payload', encoding: 'hex' }, value: 'abc' },
117
+ ];
118
+
119
+ test('serialize then parse is lossless (pull with every exchange kind)', () => {
120
+ const fixtureCase: FixtureCase = {
121
+ kind: 'pull', stream: 'items', mode: 'incremental', state: { cursor: 3 }, streamConfig: { pageSize: 2 }, config: { baseUrl: 'https://api.example.com' },
122
+ secrets: { token: '***', pin: 'x1' }, limit: 10, expect: { records: { items: 2 }, removals: {}, rejected: 0, checkpoints: 1 },
123
+ };
124
+ const files = serializeFixture('basic', { case: fixtureCase, exchanges });
125
+ expect(files.map((file) => file.path)).toEqual([`${T}/basic/case.json`, `${T}/basic/exchanges.ndjson`]);
126
+ // kind 与数据流名不写进 case.json
127
+ expect(Object.keys(JSON.parse(files[0]?.content ?? '{}'))).not.toContain('kind');
128
+ expect(Object.keys(JSON.parse(files[0]?.content ?? '{}'))).not.toContain('stream');
129
+ for (const file of files) expect(file.content.endsWith('\n')).toBe(true);
130
+ expect(files[1]?.content.split('\n')).toHaveLength(exchanges.length + 1);
131
+ expect(parseFixtures(files)).toEqual([{ capability: 'streams/items', name: 'basic', case: fixtureCase, exchanges, requests: [] }]);
132
+ // 键按插入顺序输出
133
+ expect(files[1]?.content.startsWith('{"seq":1,"kind":"http","request":{"method":"POST"')).toBe(true);
134
+ });
135
+
136
+ test('serialize then parse is lossless (push with requests)', () => {
137
+ const fixtureCase: FixtureCase = { kind: 'push', push: 'default', config: {} };
138
+ const requests: FixtureRequest[] = [
139
+ { requestId: 'r1', receivedAt: '2026-10-25T00:00:00.000Z', headers: { 'content-type': 'application/json' }, body: '{"a":1}' },
140
+ { requestId: 'r2', receivedAt: '2026-10-25T00:00:01.000Z', headers: {}, body: '' },
141
+ ];
142
+ const files = serializeFixture('hook', { case: fixtureCase, exchanges: [], requests });
143
+ expect(files.map((file) => file.path)).toEqual(['pushes/default/tests/hook/case.json', 'pushes/default/tests/hook/requests.ndjson']);
144
+ expect(parseFixtures(files)).toEqual([{ capability: 'pushes/default', name: 'hook', case: fixtureCase, exchanges: [], requests }]);
145
+ });
146
+
147
+ test('suggest cases keep kind suggest in case.json', () => {
148
+ const fixtureCase: FixtureCase = { kind: 'suggest', stream: 'customers', table: 'customers', config: {}, expect: { columns: [{ name: 'vip', type: 'string', title: 'VIP' }] } };
149
+ const files = serializeFixture('s1', { case: fixtureCase, exchanges: [] });
150
+ expect(files.map((file) => file.path)).toEqual(['streams/customers/tests/s1/case.json']);
151
+ expect(JSON.parse(files[0]?.content ?? '{}')).toMatchObject({ kind: 'suggest', table: 'customers' });
152
+ expect(parseFixtures(files)).toEqual([{ capability: 'streams/customers', name: 's1', case: fixtureCase, exchanges: [], requests: [] }]);
153
+ });
154
+
155
+ test('case.json secrets of 4 bytes or more are written as ***', () => {
156
+ const files = serializeFixture('basic', { case: { kind: 'pull', stream: 'items', mode: 'full', config: {}, secrets: { token: 'abcd1234', pin: 'x1' } }, exchanges: [] });
157
+ expect(JSON.parse(files[0]?.content ?? '{}').secrets).toEqual({ token: '***', pin: 'x1' });
158
+ });
159
+
160
+ test('an invalid case name is refused', () => {
161
+ expect(() => serializeFixture('Bad Name', { case: { kind: 'pull', stream: 'items', mode: 'full', config: {} }, exchanges: [] })).toThrow(FixtureFormatError);
162
+ });
163
+ });
164
+
165
+ describe('readFixtureDir', () => {
166
+ const temps: string[] = [];
167
+ afterAll(async () => {
168
+ await Promise.all(temps.map((dir) => rm(dir, { recursive: true, force: true })));
169
+ });
170
+
171
+ async function project(): Promise<string> {
172
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'sdk-fixtures-'));
173
+ temps.push(dir);
174
+ return dir;
175
+ }
176
+
177
+ test('reads <capability>/tests/** relative to the project, sorted, skipping dot files and other directories', async () => {
178
+ const dir = await project();
179
+ await mkdir(path.join(dir, 'streams', 'items', 'tests', 'b'), { recursive: true });
180
+ await mkdir(path.join(dir, 'streams', 'items', 'tests', 'a'), { recursive: true });
181
+ await mkdir(path.join(dir, 'operations', 'create_followup', 'tests', 'ok'), { recursive: true });
182
+ await mkdir(path.join(dir, 'fixtures', 'old'), { recursive: true });
183
+ const OPERATION_CASE = JSON.stringify({ params: {}, idempotencyKey: 'k1', config: {}, expect: { ok: {} } });
184
+ await writeFile(path.join(dir, 'streams', 'items', 'tests', 'b', 'case.json'), PULL_CASE);
185
+ await writeFile(path.join(dir, 'streams', 'items', 'tests', 'a', 'case.json'), PULL_CASE);
186
+ await writeFile(path.join(dir, 'streams', 'items', 'tests', '.DS_Store'), 'x');
187
+ await writeFile(path.join(dir, 'streams', 'items', 'index.ts'), 'export {};');
188
+ await writeFile(path.join(dir, 'operations', 'create_followup', 'tests', 'ok', 'case.json'), OPERATION_CASE);
189
+ await writeFile(path.join(dir, 'fixtures', 'old', 'case.json'), PULL_CASE);
190
+ await writeFile(path.join(dir, 'package.json'), '{}');
191
+ const files = await readFixtureDir(dir);
192
+ expect(files.map((file) => file.path).sort()).toEqual([
193
+ 'operations/create_followup/tests/ok/case.json',
194
+ `${T}/a/case.json`,
195
+ `${T}/b/case.json`,
196
+ ]);
197
+ expect(parseFixtures(files).map((fixture) => `${fixture.capability}/${fixture.name}`)).toEqual([
198
+ 'operations/create_followup/ok', 'streams/items/a', 'streams/items/b',
199
+ ]);
200
+ });
201
+
202
+ test('no tests directory gives an empty list; a symbolic link fails', async () => {
203
+ expect(await readFixtureDir(await project())).toEqual([]);
204
+ const dir = await project();
205
+ await mkdir(path.join(dir, 'streams', 'items', 'tests', 'a'), { recursive: true });
206
+ await writeFile(path.join(dir, 'outside.json'), PULL_CASE);
207
+ await symlink(path.join(dir, 'outside.json'), path.join(dir, 'streams', 'items', 'tests', 'a', 'case.json'));
208
+ const error = await readFixtureDir(dir).then(() => null, (caught: unknown) => caught);
209
+ expect(error).toBeInstanceOf(FixtureFormatError);
210
+ expect((error as FixtureFormatError).file).toBe(`${T}/a/case.json`);
211
+ });
212
+ });
213
+
214
+ describe('compliance codes', () => {
215
+ test('COMPLIANCE_CODES lists every code, including UNDECLARED_FIELDS', () => {
216
+ expect([...COMPLIANCE_CODES].sort()).toEqual([
217
+ 'CHECKPOINT_NOT_MONOTONIC', 'EXPECTATION_FAILED', 'FIXTURE_EXCHANGE_MISMATCH', 'FIXTURE_EXCHANGE_UNUSED',
218
+ 'FULL_READ_NOT_REPEATABLE', 'PRIMARY_KEY_DUPLICATE', 'RECORD_SCHEMA_MISMATCH', 'UNDECLARED_FIELDS', 'UNDECLARED_TABLE',
219
+ ]);
220
+ });
221
+ });