@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,160 @@
1
+ /**
2
+ * 连接器操作的本地运行与用例:经本地模拟网关执行并录制、用例往返、不联网回放与期望比较、用例格式。
3
+ */
4
+ import { describe, expect, test } from 'bun:test';
5
+ import { defineOperation, defineStream, errors } from '../../src';
6
+ import {
7
+ createInProcessExecutor, createLocalGateway, createRecorder, parseFixtures, runFixtures, runOperation, serializeFixture,
8
+ type FixtureCase, type FixtureExchange, type GuestOperationResult,
9
+ } from '../../src/testing';
10
+ import { configField, descriptor, operation, stream, table, valueField } from '../fixtures/descriptor';
11
+
12
+ const SECRETS = { token: 'tok-12345' };
13
+
14
+ type D = {
15
+ config: Record<never, never>;
16
+ secrets: { token: string };
17
+ streams: { orders: { tables: { orders: { id: string } }; params: Record<never, never> } };
18
+ extensibleStreams: Record<never, never>;
19
+ pushes: Record<never, never>;
20
+ operations: { create_order: { input: { sku: string; qty: number }; output: { externalId: string; status: string }; idempotency: 'supported' } };
21
+ };
22
+
23
+ const DESCRIPTOR = descriptor({
24
+ name: 'erp', hosts: ['erp.example.com'], config: { token: configField('secret', { exposeToScript: true }) },
25
+ streams: { orders: stream({ orders: table({ id: 'string' }, ['id']) }, { syncModes: ['full'] }) },
26
+ operations: {
27
+ create_order: operation({ sku: valueField('string'), qty: valueField('integer') }, { externalId: valueField('string'), status: valueField('string') }, { risk: 'high' }),
28
+ },
29
+ });
30
+
31
+ const connector = {
32
+ descriptor: DESCRIPTOR,
33
+ implementations: {
34
+ streams: { orders: defineStream<D, 'orders'>('orders', async function* () {}) },
35
+ operations: {
36
+ create_order: defineOperation<D, 'create_order'>('create_order', {
37
+ async run(ctx, params, meta) {
38
+ const res = await ctx.http.post<{ id?: string; status?: string; code?: string }>('/api/orders', {
39
+ headers: { authorization: `Bearer ${ctx.secrets.token}`, 'idempotency-key': meta.idempotencyKey },
40
+ body: params,
41
+ raw: true,
42
+ });
43
+ const data = JSON.parse(res.text) as { id?: string; status?: string; code?: string };
44
+ if (res.status === 422) throw new errors.RejectedError('rejected by erp', data.code !== undefined ? { code: data.code } : undefined);
45
+ return { externalId: String(data.id), status: String(data.status) };
46
+ },
47
+ }),
48
+ },
49
+ },
50
+ };
51
+
52
+ /** 假的业务系统:qty > 10 时 422 OUT_OF_STOCK,否则建单。 */
53
+ function erpFetch(seen: Array<{ url: string; authorization: string | null }>): typeof fetch {
54
+ return (async (input: string | URL | Request, init?: RequestInit) => {
55
+ const body = JSON.parse(new TextDecoder().decode(init?.body as Uint8Array)) as { qty: number };
56
+ seen.push({ url: String(input), authorization: new Headers(init?.headers).get('authorization') });
57
+ if (body.qty > 10) return new Response(JSON.stringify({ code: 'OUT_OF_STOCK' }), { status: 422, headers: { 'content-type': 'application/json' } });
58
+ return new Response(JSON.stringify({ id: 'SO-1', status: 'created' }), { status: 201, headers: { 'content-type': 'application/json' } });
59
+ }) as typeof fetch;
60
+ }
61
+
62
+ async function record(params: Record<string, unknown>): Promise<{ result: GuestOperationResult; exchanges: FixtureExchange[]; seen: Array<{ url: string; authorization: string | null }> }> {
63
+ const seen: Array<{ url: string; authorization: string | null }> = [];
64
+ const recorder = createRecorder({ secrets: SECRETS });
65
+ const gateway = createLocalGateway({ hosts: ['erp.example.com'], secrets: SECRETS, fetch: erpFetch(seen), onExchange: (exchange) => recorder.onExchange(exchange) });
66
+ const result = await runOperation(createInProcessExecutor(connector), {
67
+ config: {}, operation: 'create_order', params, idempotencyKey: 'stable:abc', host: gateway,
68
+ });
69
+ await gateway.close();
70
+ return { result, exchanges: recorder.exchanges(), seen };
71
+ }
72
+
73
+ function operationCase(params: Record<string, unknown>, expectation: Extract<FixtureCase, { kind: 'operation' }>['expect']): FixtureCase {
74
+ return { kind: 'operation', operation: 'create_order', params, idempotencyKey: 'stable:abc', config: {}, secrets: SECRETS, expect: expectation };
75
+ }
76
+
77
+ describe('runOperation 经本地模拟网关', () => {
78
+ test('返回 GuestOperationResult,请求经模拟网关(凭证由网关替换)并进入录制器', async () => {
79
+ const { result, exchanges, seen } = await record({ sku: 'A-1', qty: 2 });
80
+ expect(result).toMatchObject({ kind: 'operation', phase: 'run', ok: true, output: { externalId: 'SO-1', status: 'created' } });
81
+ expect(seen).toEqual([{ url: 'https://erp.example.com/api/orders', authorization: 'Bearer tok-12345' }]);
82
+ expect(exchanges).toHaveLength(1);
83
+ expect(exchanges[0]).toMatchObject({ kind: 'http', request: { method: 'POST', url: 'https://erp.example.com/api/orders', headers: { authorization: '***', 'idempotency-key': 'stable:abc' } } });
84
+ });
85
+
86
+ test('外部系统拒绝时 REJECTED 带外部业务码;simulate 阶段没声明 simulate 时 FATAL_ERROR', async () => {
87
+ const { result } = await record({ sku: 'A-1', qty: 99 });
88
+ expect(result).toMatchObject({ ok: false, code: 'REJECTED', external: { code: 'OUT_OF_STOCK' } });
89
+ const gateway = createLocalGateway({ hosts: [], secrets: {} });
90
+ const simulated = await runOperation(createInProcessExecutor(connector), {
91
+ config: {}, operation: 'create_order', params: { sku: 'A-1', qty: 2 }, idempotencyKey: 'k', phase: 'simulate', host: gateway,
92
+ });
93
+ expect(simulated).toMatchObject({ phase: 'simulate', ok: false, code: 'FATAL_ERROR' });
94
+ });
95
+ });
96
+
97
+ describe('操作夹具', () => {
98
+ test('录制一次成功调用后 serializeFixture → parseFixtures 得到 operation 用例,凭证被遮蔽,expect.ok 等于输出', async () => {
99
+ const { result, exchanges } = await record({ sku: 'A-1', qty: 2 });
100
+ if (!result.ok || result.phase !== 'run') throw new Error('expected a successful run');
101
+ const files = serializeFixture('create-ok', { case: operationCase({ sku: 'A-1', qty: 2 }, { ok: result.output }), exchanges });
102
+ expect(files.map((file) => file.content).join('')).not.toContain('tok-12345');
103
+ const [fixture] = parseFixtures(files);
104
+ expect(fixture?.case).toEqual({ ...operationCase({ sku: 'A-1', qty: 2 }, { ok: { externalId: 'SO-1', status: 'created' } }), secrets: { token: '***' } });
105
+ expect(fixture?.exchanges).toEqual(exchanges);
106
+ });
107
+
108
+ test('回放不联网:成功与 REJECTED 用例都通过;输出与 expect.ok 不同时失败并给出差异', async () => {
109
+ const ok = await record({ sku: 'A-1', qty: 2 });
110
+ const rejected = await record({ sku: 'A-1', qty: 99 });
111
+ if (!ok.result.ok || ok.result.phase !== 'run') throw new Error('expected a successful run');
112
+ const files = [
113
+ ...serializeFixture('create-ok', { case: operationCase({ sku: 'A-1', qty: 2 }, { ok: ok.result.output }), exchanges: ok.exchanges }),
114
+ ...serializeFixture('create-rejected', { case: operationCase({ sku: 'A-1', qty: 99 }, { error: 'REJECTED' }), exchanges: rejected.exchanges }),
115
+ ...serializeFixture('create-wrong', { case: operationCase({ sku: 'A-1', qty: 2 }, { ok: { externalId: 'SO-2', status: 'created' } }), exchanges: ok.exchanges }),
116
+ ];
117
+ const results = await runFixtures(createInProcessExecutor(connector), DESCRIPTOR, files);
118
+ const byName = Object.fromEntries(results.map((item) => [item.name, item]));
119
+ expect(byName['create-ok']).toMatchObject({ ok: true, error: null, findings: [] });
120
+ expect(byName['create-rejected']).toMatchObject({ ok: true, error: null, findings: [] });
121
+ const wrong = byName['create-wrong'];
122
+ expect(wrong?.ok).toBe(false);
123
+ expect(wrong?.findings).toHaveLength(1);
124
+ expect(wrong?.findings[0]).toMatchObject({
125
+ code: 'EXPECTATION_FAILED',
126
+ detail: { differences: [{ path: 'output.externalId', expected: 'SO-2', actual: 'SO-1' }] },
127
+ });
128
+ });
129
+
130
+ test('期望失败码却成功、或请求与录制不符,都不通过', async () => {
131
+ const ok = await record({ sku: 'A-1', qty: 2 });
132
+ const files = [
133
+ ...serializeFixture('expect-error', { case: operationCase({ sku: 'A-1', qty: 2 }, { error: 'REJECTED' }), exchanges: ok.exchanges }),
134
+ ...serializeFixture('mismatch', { case: operationCase({ sku: 'B-2', qty: 2 }, { ok: { externalId: 'SO-1', status: 'created' } }), exchanges: ok.exchanges }),
135
+ ];
136
+ const results = await runFixtures(createInProcessExecutor(connector), DESCRIPTOR, files);
137
+ const byName = Object.fromEntries(results.map((item) => [item.name, item]));
138
+ expect(byName['expect-error']?.ok).toBe(false);
139
+ expect(byName['expect-error']?.findings.map((finding) => finding.code)).toEqual(['EXPECTATION_FAILED']);
140
+ expect(byName.mismatch?.ok).toBe(false);
141
+ expect(byName.mismatch?.findings.map((finding) => finding.code)).toContain('FIXTURE_EXCHANGE_MISMATCH');
142
+ });
143
+
144
+ test('operation 用例格式:expect 必填且二选一;未知字段报错', () => {
145
+ const write = (value: unknown) => [{ path: 'operations/create_order/tests/op/case.json', content: JSON.stringify(value) }];
146
+ const base = { params: {}, idempotencyKey: 'k', config: {} };
147
+ expect(() => parseFixtures(write(base))).toThrow(/expect/);
148
+ expect(() => parseFixtures(write({ ...base, expect: { ok: {}, error: 'REJECTED' } }))).toThrow(/exactly one/);
149
+ expect(() => parseFixtures(write({ ...base, expect: { error: 'REJECTED' }, extra: 1 }))).toThrow(/unknown field/);
150
+ expect(parseFixtures(write({ ...base, expect: { error: 'REJECTED' } }))[0]?.case).toMatchObject({ kind: 'operation', operation: 'create_order' });
151
+ });
152
+
153
+ test('serializeFixture 把操作用例写到 operations/<名>/tests/<用例名>/,case.json 不含 kind 与 operation', () => {
154
+ const files = serializeFixture('create-ok', { case: operationCase({ sku: 'A-1', qty: 2 }, { ok: {} }), exchanges: [] });
155
+ expect(files.map((file) => file.path)).toEqual(['operations/create_order/tests/create-ok/case.json']);
156
+ const written = JSON.parse(files[0]?.content ?? '{}') as Record<string, unknown>;
157
+ expect(Object.keys(written)).not.toContain('kind');
158
+ expect(Object.keys(written)).not.toContain('operation');
159
+ });
160
+ });
@@ -0,0 +1,163 @@
1
+ /**
2
+ * 录制:遮蔽规则(明文、凭证类头、句柄原样)、db 分页合并、crypto;经本地模拟网关端到端录制。
3
+ */
4
+ import { describe, expect, test } from 'bun:test';
5
+ import * as shared from '@mu-ontos/shared';
6
+ import { defineStream } from '../../src';
7
+ import {
8
+ createLocalGateway, createRecorder, parseFixtures, runStream, serializeFixture, type FixtureExchange, type LocalExchange,
9
+ } from '../../src/testing';
10
+ import { localPgUnavailable } from '../fixtures/acceptance';
11
+ import { configField, descriptor, stream, table } from '../fixtures/descriptor';
12
+
13
+ const pgUnavailable = await localPgUnavailable();
14
+ if (pgUnavailable) console.warn(`[recorder] 本机 :15432 没有 Postgres,跳过真库用例:${pgUnavailable}`);
15
+
16
+ const SECRETS = { token: 'abcd1234', pin: 'x1' };
17
+
18
+ type HttpExchange = Extract<FixtureExchange, { kind: 'http' }>;
19
+ type DbExchange = Extract<FixtureExchange, { kind: 'db' }>;
20
+
21
+ describe('redaction while recording', () => {
22
+ test('plaintext secrets of 4+ bytes and credential headers become ***; handles and short secrets stay', async () => {
23
+ const seen: Array<{ url: string; authorization: string | null; body: string }> = [];
24
+ const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
25
+ seen.push({ url: String(input), authorization: new Headers(init?.headers).get('authorization'), body: String(init?.body ? new TextDecoder().decode(init.body as Uint8Array) : '') });
26
+ return new Response(JSON.stringify({ echo: 'abcd1234', pin: 'x1' }), { headers: { 'content-type': 'application/json', 'set-cookie': 'sid=1' } });
27
+ }) as typeof fetch;
28
+ const secrets = { ...SECRETS, handle: 'h4ndle-secret' };
29
+ const recorder = createRecorder({ secrets });
30
+ const gateway = createLocalGateway({ hosts: ['api.example.com'], secrets, fetch: fetchImpl, onExchange: (exchange) => recorder.onExchange(exchange) });
31
+
32
+ type D = {
33
+ config: Record<never, never>; secrets: { token: string; pin: string; handle: string };
34
+ streams: { items: { tables: { item: { id: string } }; params: Record<never, never> } }; extensibleStreams: Record<never, never>; pushes: Record<never, never>; operations: Record<never, never>;
35
+ };
36
+ const connector = {
37
+ descriptor: descriptor({
38
+ name: 'redact', hosts: ['api.example.com'],
39
+ config: { token: configField('secret', { exposeToScript: true }), pin: configField('secret', { exposeToScript: true }), handle: configField('secret') },
40
+ streams: { items: stream({ item: table({ id: 'string' }, ['id']) }, { syncModes: ['full'] }) },
41
+ }),
42
+ implementations: {
43
+ streams: {
44
+ items: defineStream<D, 'items'>('items', async function* (ctx) {
45
+ await ctx.http.post('https://api.example.com/v1/items', {
46
+ query: { sig: ctx.secrets.handle, page: '1' },
47
+ headers: { authorization: `Bearer ${ctx.secrets.token}`, 'x-trace': `t-${ctx.secrets.token}`, 'x-pin': ctx.secrets.pin },
48
+ body: { handle: ctx.secrets.handle, pin: ctx.secrets.pin },
49
+ });
50
+ yield ctx.record('item', { id: '1' });
51
+ }),
52
+ },
53
+ },
54
+ };
55
+ const recorded = await runStream(connector, { stream: 'items', config: {}, host: gateway });
56
+ expect(recorded.error).toBeNull();
57
+ // 上游收到的是替换后的明文
58
+ expect(seen[0]?.authorization).toBe('Bearer abcd1234');
59
+ expect(seen[0]?.url).toContain('sig=h4ndle-secret');
60
+ expect(seen[0]?.body).toBe('{"handle":"h4ndle-secret","pin":"x1"}');
61
+
62
+ const [exchange] = recorder.exchanges() as HttpExchange[];
63
+ expect(exchange?.request.headers).toEqual({ authorization: '***', 'x-trace': 't-***', 'x-pin': 'x1', 'content-type': 'application/json' });
64
+ expect(exchange?.request.url).toBe('https://api.example.com/v1/items?page=1&sig=***');
65
+ expect(exchange?.request.body).toBe('{"handle":"ontos:secret:handle","pin":"x1"}');
66
+ expect(exchange?.response?.body).toBe('{"echo":"***","pin":"x1"}');
67
+ expect(exchange?.response?.headers['set-cookie']).toBe('***');
68
+ expect(JSON.stringify(recorder.exchanges())).not.toContain('abcd1234');
69
+ expect(JSON.stringify(recorder.exchanges())).not.toContain('h4ndle-secret');
70
+
71
+ const files = serializeFixture('redact', { case: { kind: 'pull', stream: 'items', mode: 'full', config: {}, secrets }, exchanges: recorder.exchanges() });
72
+ expect(JSON.parse(files[0]?.content ?? '{}').secrets).toEqual({ token: '***', pin: 'x1', handle: '***' });
73
+ expect(files.map((file) => file.content).join('')).not.toContain('abcd1234');
74
+ expect(parseFixtures(files)[0]?.exchanges).toEqual(recorder.exchanges());
75
+ });
76
+
77
+ test('the recorder applies the same header and URL rules as shared connector-redaction', () => {
78
+ const recorder = createRecorder({ secrets: SECRETS });
79
+ const headers = { Authorization: 'Bearer abcd1234', Cookie: 'a=b', 'X-Api-Key': 'k', 'Proxy-Authorization': 'p', 'X-Auth-Token': 'q', 'X-Other': 'abcd1234!' };
80
+ recorder.onExchange({
81
+ kind: 'http',
82
+ request: { method: 'GET', url: 'https://api.example.com/x?z=1&key=ontos%3Asecret%3Atoken', headers, query: { a: 'abcd1234' }, body: null, bodyEncoding: 'utf8' },
83
+ response: { status: 204, url: 'https://api.example.com/x', headers: {}, body: '', bodyEncoding: 'utf8' },
84
+ error: null,
85
+ durationMs: 1,
86
+ });
87
+ const [exchange] = recorder.exchanges() as HttpExchange[];
88
+ expect(exchange?.request.headers).toEqual(shared.redactHeaders(headers, Object.values(SECRETS)));
89
+ expect(exchange?.request.url).toBe('https://api.example.com/x?a=***&key=***&z=1');
90
+ });
91
+
92
+ test('errors are recorded with redacted messages; base64 bodies are kept as is', () => {
93
+ const recorder = createRecorder({ secrets: SECRETS });
94
+ recorder.onExchange({
95
+ kind: 'http',
96
+ request: { method: 'POST', url: 'https://api.example.com/x', headers: {}, query: {}, body: 'AAEC', bodyEncoding: 'base64' },
97
+ response: null,
98
+ error: { code: 'EGRESS_UPSTREAM_FAILED', message: 'failed for abcd1234' },
99
+ durationMs: 1,
100
+ });
101
+ expect(recorder.exchanges()).toEqual([{
102
+ seq: 1, kind: 'http',
103
+ request: { method: 'POST', url: 'https://api.example.com/x', headers: {}, body: 'AAEC', bodyEncoding: 'base64' },
104
+ response: null,
105
+ error: { code: 'EGRESS_UPSTREAM_FAILED', message: 'failed for ***' },
106
+ }]);
107
+ });
108
+ });
109
+
110
+ describe('db and crypto', () => {
111
+ const page = (rows: number[], cursorId: string | null, done: boolean) => ({ cursorId, fields: [{ name: 'n', dataTypeId: 23 }], rows: rows.map((n) => ({ n })), done });
112
+
113
+ test('a query fetched in 3 pages becomes one db exchange holding every row', () => {
114
+ const recorder = createRecorder({ secrets: SECRETS });
115
+ const exchanges: LocalExchange[] = [
116
+ { kind: 'db', request: { sql: 'select n from t where k = $1', params: ['abcd1234'] }, response: page([1, 2], 'c1', false), error: null, durationMs: 1 },
117
+ { kind: 'db', request: { cursorId: 'c1' }, response: { rows: [{ n: 3 }, { n: 4 }], done: false }, error: null, durationMs: 1 },
118
+ { kind: 'crypto', request: { op: 'hash', algorithm: 'sha256', data: 'ontos:secret:token', encoding: 'hex' }, response: { value: 'h' }, error: null, durationMs: 1 },
119
+ { kind: 'db', request: { cursorId: 'c1' }, response: { rows: [{ n: 5 }], done: true }, error: null, durationMs: 1 },
120
+ ];
121
+ for (const exchange of exchanges) recorder.onExchange(exchange);
122
+ expect(recorder.exchanges()).toEqual([
123
+ { seq: 1, kind: 'db', sql: 'select n from t where k = $1', params: ['***'], fields: [{ name: 'n', dataTypeId: 23 }], rows: [1, 2, 3, 4, 5].map((n) => ({ n })), error: null },
124
+ { seq: 2, kind: 'crypto', request: { op: 'hash', algorithm: 'sha256', data: 'ontos:secret:token', encoding: 'hex' }, value: 'h' },
125
+ ]);
126
+ });
127
+
128
+ test('a cursor that fails midway keeps the rows fetched so far and the error', () => {
129
+ const recorder = createRecorder({ secrets: {} });
130
+ recorder.onExchange({ kind: 'db', request: { sql: 'select 1', params: [] }, response: page([1], 'c9', false), error: null, durationMs: 1 });
131
+ recorder.onExchange({ kind: 'db', request: { cursorId: 'c9' }, response: null, error: { code: 'DB_PROXY_FAILED', message: 'boom' }, durationMs: 1 });
132
+ recorder.onExchange({ kind: 'db', request: { cursorId: 'c9' }, response: { rows: [{ n: 2 }], done: true }, error: null, durationMs: 1 });
133
+ const [exchange] = recorder.exchanges() as DbExchange[];
134
+ expect(exchange?.rows).toEqual([{ n: 1 }]);
135
+ expect(exchange?.error).toEqual({ code: 'DB_PROXY_FAILED', message: 'boom' });
136
+ });
137
+
138
+ // 本机没有 :15432 的 Postgres 时跳过
139
+ test.skipIf(Boolean(pgUnavailable))('a real Postgres (localhost:15432) query read in 3 cursor pages is recorded as one exchange', async () => {
140
+ const recorder = createRecorder({ secrets: {} });
141
+ const gateway = createLocalGateway({
142
+ hosts: [], secrets: {}, db: { host: 'localhost', port: 15432, database: 'postgres', user: 'admin', password: 'password' },
143
+ onExchange: (exchange) => recorder.onExchange(exchange),
144
+ });
145
+ try {
146
+ let response = await gateway.call('db.query', {
147
+ sql: 'select n from generate_series(1, 2500) as n', params: [],
148
+ }) as { cursorId: string | null; done: boolean };
149
+ let pages = 1;
150
+ while (!response.done && response.cursorId) {
151
+ response = { ...(await gateway.call('db.next', { cursorId: response.cursorId }) as { done: boolean }), cursorId: response.cursorId };
152
+ pages += 1;
153
+ }
154
+ expect(pages).toBe(3);
155
+ const exchanges = recorder.exchanges() as DbExchange[];
156
+ expect(exchanges).toHaveLength(1);
157
+ expect(exchanges[0]?.rows).toHaveLength(2500);
158
+ expect(exchanges[0]?.rows[2499]).toEqual({ n: 2500 });
159
+ } finally {
160
+ await gateway.close();
161
+ }
162
+ });
163
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * SDK 的遮蔽实现与 shared `connector-redaction.ts` 的对照:SDK 不能依赖 shared,两份实现的结论必须一致。
3
+ */
4
+ import { describe, expect, test } from 'bun:test';
5
+ import * as shared from '@mu-ontos/shared';
6
+ import { CREDENTIAL_HEADER_NAMES, redactHeaders, redactQuery, redactSecretValues, redactUrl } from '../../src/testing';
7
+
8
+ const SECRETS = ['abcd1234', 'abcd', 'x1', 'longer-secret-value'];
9
+
10
+ const TEXT_SAMPLES = [
11
+ 'Bearer abcd1234',
12
+ 'token=abcd&other=abcd1234',
13
+ 'pin x1 stays',
14
+ 'nested longer-secret-value and abcd',
15
+ '',
16
+ 'no secrets here',
17
+ ];
18
+
19
+ const HEADER_SAMPLES: Array<Record<string, string>> = [
20
+ { Authorization: 'Bearer abcd1234', 'X-Trace': 'abcd1234-trace' },
21
+ { cookie: 'session=1', 'set-cookie': 'a=b', 'X-API-Key': 'k', 'content-type': 'application/json' },
22
+ { 'x-auth-token': 'ontos:secret:token', accept: 'x1' },
23
+ ];
24
+
25
+ describe('redaction matches shared connector-redaction', () => {
26
+ test('credential header names are identical', () => {
27
+ expect([...CREDENTIAL_HEADER_NAMES]).toEqual([...shared.CREDENTIAL_HEADER_NAMES]);
28
+ });
29
+
30
+ test.each(TEXT_SAMPLES)('redactSecretValues(%p)', (text) => {
31
+ expect(redactSecretValues(text, SECRETS)).toBe(shared.redactSecretValues(text, SECRETS));
32
+ });
33
+
34
+ test.each(HEADER_SAMPLES)('redactHeaders(%p)', (headers) => {
35
+ expect(redactHeaders(headers, SECRETS)).toEqual(shared.redactHeaders(headers, SECRETS));
36
+ });
37
+
38
+ test('longer secrets are replaced first and short values stay', () => {
39
+ expect(redactSecretValues('abcd1234 abcd x1', SECRETS)).toBe('*** *** x1');
40
+ });
41
+ });
42
+
43
+ describe('query and url redaction', () => {
44
+ test('query values that contain a secret handle are masked; the handle form is never revealed', () => {
45
+ expect(redactQuery({ token: 'ontos:secret:token', page: '2', list: ['a', 'ontos:secret:x'] }, [])).toEqual({
46
+ token: '***', page: '2', list: ['a', '***'],
47
+ });
48
+ });
49
+
50
+ test('url query handles and plaintext secrets are masked', () => {
51
+ expect(redactUrl('https://api.example.com/x?key=ontos%3Asecret%3Atoken&p=1', [])).toBe('https://api.example.com/x?key=***&p=1');
52
+ expect(redactUrl('https://api.example.com/abcd1234/x', SECRETS)).toBe('https://api.example.com/***/x');
53
+ });
54
+ });
@@ -0,0 +1,207 @@
1
+ /**
2
+ * 用例回放:按序应答、不匹配时报错且绝不联网、未用往来、secret / cache / crypto / db 的应答。
3
+ */
4
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
5
+ import { createHash } from 'node:crypto';
6
+ import path from 'node:path';
7
+ import { defineStream, type ConnectorDescriptor } from '../../src';
8
+ import {
9
+ createInProcessExecutor, createReplayHost, FixtureReplayError, runFixture, type FixtureExchange, type LoadedFixture,
10
+ } from '../../src/testing';
11
+ import { HTTP_PULL_DIR } from '../fixtures/acceptance';
12
+ import { configField, descriptor, stream, table } from '../fixtures/descriptor';
13
+
14
+ const CONFIG = { baseUrl: 'https://api.example.com/v1' };
15
+
16
+ function httpExchange(seq: number, url: string, body: unknown): FixtureExchange {
17
+ return {
18
+ seq, kind: 'http',
19
+ request: { method: 'GET', url, headers: { authorization: '***' }, body: null, bodyEncoding: 'utf8' },
20
+ response: { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), bodyEncoding: 'utf8' },
21
+ error: null,
22
+ };
23
+ }
24
+
25
+ const TWO_PAGES: FixtureExchange[] = [
26
+ httpExchange(1, 'https://api.example.com/v1/items', { items: [{ id: 'a', name: 'A', updatedAt: '2026-10-01T00:00:00Z' }], nextCursor: 'p2' }),
27
+ httpExchange(2, 'https://api.example.com/v1/items?cursor=p2', { items: [{ id: 'b', name: 'B', updatedAt: '2026-10-02T00:00:00Z' }], nextCursor: null }),
28
+ ];
29
+
30
+ function pullFixture(exchanges: FixtureExchange[], overrides: Partial<Extract<LoadedFixture['case'], { kind: 'pull' }>> = {}): LoadedFixture {
31
+ return { capability: 'streams/items', name: 'basic', case: { kind: 'pull', stream: 'items', mode: 'incremental', config: CONFIG, secrets: { token: '***' }, ...overrides }, exchanges, requests: [] };
32
+ }
33
+
34
+ const HTTP_PULL_DESCRIPTOR = JSON.parse(await Bun.file(path.join(HTTP_PULL_DIR, '.ontos/descriptor.json')).text()) as ConnectorDescriptor;
35
+
36
+ /** 验收连接器 http_pull 的进程内执行器。 */
37
+ async function httpPull() {
38
+ const items = ((await import(path.join(HTTP_PULL_DIR, 'streams/items/index.ts'))) as { default: unknown }).default;
39
+ return createInProcessExecutor({ descriptor: HTTP_PULL_DESCRIPTOR, implementations: { streams: { items } } });
40
+ }
41
+
42
+ /** 回放期间把全局 fetch 换成会抛错的替身,记下有没有被调用。 */
43
+ let fetchCalls = 0;
44
+ const realFetch = globalThis.fetch;
45
+ beforeEach(() => {
46
+ fetchCalls = 0;
47
+ globalThis.fetch = (async () => {
48
+ fetchCalls += 1;
49
+ throw new Error('fixture replay must never reach the network');
50
+ }) as unknown as typeof fetch;
51
+ });
52
+ afterEach(() => {
53
+ globalThis.fetch = realFetch;
54
+ });
55
+
56
+ describe('http', () => {
57
+ test('two recorded GETs answer the same two requests in order; nothing is left unused', async () => {
58
+ const fixture = pullFixture(TWO_PAGES);
59
+ const host = createReplayHost(fixture);
60
+ const request = (url: string, query: Record<string, string> = {}) => ({
61
+ method: 'GET', url, headers: { authorization: 'Bearer ontos:secret:token' }, query, body: null, bodyEncoding: 'utf8',
62
+ });
63
+ const first = await host.call('http', request('https://api.example.com/v1/items')) as { status: number; body: string; url: string };
64
+ expect(first.status).toBe(200);
65
+ expect(JSON.parse(first.body).nextCursor).toBe('p2');
66
+ expect(first.url).toBe('https://api.example.com/v1/items');
67
+ const second = await host.call('http', request('https://api.example.com/v1/items', { cursor: 'p2' })) as { body: string };
68
+ expect(JSON.parse(second.body).nextCursor).toBeNull();
69
+ expect(host.unused()).toEqual([]);
70
+ expect(host.mismatches()).toEqual([]);
71
+
72
+ const result = await runFixture(await httpPull(), HTTP_PULL_DESCRIPTOR, fixture);
73
+ expect(result).toMatchObject({ name: 'basic', ok: true, findings: [], error: null, stats: { records: { items: 2 }, checkpoints: 2 } });
74
+ expect(fetchCalls).toBe(0);
75
+ });
76
+
77
+ test('a request the fixture does not have fails with FIXTURE_EXCHANGE_MISMATCH and never reaches fetch', async () => {
78
+ const host = createReplayHost(pullFixture(TWO_PAGES));
79
+ const base = { headers: {}, query: {}, bodyEncoding: 'utf8' };
80
+ const codeOf = (payload: unknown) => host.call('http', payload).then(() => null, (error: unknown) => (error as FixtureReplayError).code);
81
+ expect(await codeOf({ ...base, method: 'POST', url: 'https://api.example.com/v1/items', body: null })).toBe('FIXTURE_EXCHANGE_MISMATCH');
82
+ expect(await codeOf({ ...base, method: 'GET', url: 'https://api.example.com/v1/other', body: null })).toBe('FIXTURE_EXCHANGE_MISMATCH');
83
+ expect(await codeOf({ ...base, method: 'GET', url: 'https://api.example.com/v1/items', body: '{"x":1}' })).toBe('FIXTURE_EXCHANGE_MISMATCH');
84
+ expect(host.mismatches().map((item) => item.method)).toEqual(['http', 'http', 'http']);
85
+ expect(host.unused()).toHaveLength(2);
86
+
87
+ // 连接器发出夹具里没有的请求(第二页的游标不同):运行失败,结果带 FIXTURE_EXCHANGE_MISMATCH
88
+ const fixture = pullFixture([TWO_PAGES[0] as FixtureExchange, httpExchange(2, 'https://api.example.com/v1/items?cursor=p9', { items: [], nextCursor: null })]);
89
+ const result = await runFixture(await httpPull(), HTTP_PULL_DESCRIPTOR, fixture);
90
+ expect(result.ok).toBe(false);
91
+ expect(result.error).not.toBeNull();
92
+ expect(result.findings.map((finding) => finding.code)).toEqual(['FIXTURE_EXCHANGE_MISMATCH']);
93
+ expect(String((result.findings[0]?.detail as { detail: string }).detail)).toContain('cursor=p2');
94
+ expect(fetchCalls).toBe(0);
95
+ });
96
+
97
+ test('an unused recorded exchange is reported, but the case still passes', async () => {
98
+ const fixture = pullFixture([...TWO_PAGES, httpExchange(3, 'https://api.example.com/v1/extra', {})]);
99
+ const result = await runFixture(await httpPull(), HTTP_PULL_DESCRIPTOR, fixture);
100
+ expect(result.ok).toBe(true);
101
+ expect(result.findings.map((finding) => finding.code)).toEqual(['FIXTURE_EXCHANGE_UNUSED']);
102
+ expect(result.findings[0]?.detail).toEqual({ seq: 3, kind: 'http' });
103
+ });
104
+
105
+ test('a recorded error is replayed as the same host call failure', async () => {
106
+ const host = createReplayHost(pullFixture([{
107
+ seq: 1, kind: 'http', request: { method: 'GET', url: 'https://api.example.com/x', headers: {}, body: null, bodyEncoding: 'utf8' },
108
+ response: null, error: { code: 'EGRESS_TIMEOUT', message: 'The outbound request timed out' },
109
+ }]));
110
+ const error = await host.call('http', { method: 'GET', url: 'https://api.example.com/x', headers: {}, query: {}, body: null, bodyEncoding: 'utf8' })
111
+ .then(() => null, (caught: unknown) => caught as FixtureReplayError);
112
+ expect(error?.code).toBe('EGRESS_TIMEOUT');
113
+ expect(host.unused()).toEqual([]);
114
+ });
115
+ });
116
+
117
+ describe('secret, cache and crypto', () => {
118
+ test('secret.reveal answers from case.secrets (even ***); cache lives for one replay', async () => {
119
+ const fixture = pullFixture([], { secrets: { token: '***', pin: 'x1' } });
120
+ const host = createReplayHost(fixture);
121
+ expect(await host.call('secret.reveal', { field: 'token' })).toEqual({ value: '***' });
122
+ expect(await host.call('secret.reveal', { field: 'pin' })).toEqual({ value: 'x1' });
123
+ const missing = await host.call('secret.reveal', { field: 'other' }).then(() => null, (error: unknown) => (error as FixtureReplayError).code);
124
+ expect(missing).toBe('EGRESS_SECRET_UNAVAILABLE');
125
+ expect(await host.call('cache.get', { key: 'k' })).toBeNull();
126
+ await host.call('cache.set', { key: 'k', value: 'v', ttlSeconds: 60 });
127
+ expect(await host.call('cache.get', { key: 'k' })).toMatchObject({ value: 'v' });
128
+ expect(await createReplayHost(fixture).call('cache.get', { key: 'k' })).toBeNull();
129
+ });
130
+
131
+ test('crypto: recorded results first, hash computed locally, an unrecorded hmac or sign is a mismatch', async () => {
132
+ const hmac = { op: 'hmac', algorithm: 'sha256', key: 'ontos:secret:token', data: 'payload', encoding: 'hex' } as const;
133
+ const host = createReplayHost(pullFixture([{ seq: 1, kind: 'crypto', request: hmac, value: 'recorded-signature' }], { secrets: { token: '***' } }));
134
+ expect(await host.call('crypto', hmac)).toEqual({ value: 'recorded-signature' });
135
+ expect(await host.call('crypto', { op: 'hash', algorithm: 'sha256', data: 'abc', encoding: 'hex' }))
136
+ .toEqual({ value: createHash('sha256').update('abc').digest('hex') });
137
+ expect(await host.call('crypto', { op: 'hash', algorithm: 'md5', data: 'k=ontos:secret:token', encoding: 'base64' }))
138
+ .toEqual({ value: createHash('md5').update('k=***').digest('base64') });
139
+ const again = await host.call('crypto', hmac).then(() => null, (error: unknown) => (error as FixtureReplayError).code);
140
+ expect(again).toBe('FIXTURE_EXCHANGE_MISMATCH');
141
+ const sign = await host.call('crypto', { op: 'sign', algorithm: 'rsa-sha256', key: 'k', data: 'd', encoding: 'base64' })
142
+ .then(() => null, (error: unknown) => (error as FixtureReplayError).code);
143
+ expect(sign).toBe('FIXTURE_EXCHANGE_MISMATCH');
144
+ });
145
+
146
+ test('a connector that signs requests replays from recorded crypto exchanges in process', async () => {
147
+ type D = {
148
+ config: Record<never, never>; secrets: { key: string };
149
+ streams: { items: { tables: { item: { sig: string } }; params: Record<never, never> } }; extensibleStreams: Record<never, never>; pushes: Record<never, never>; operations: Record<never, never>;
150
+ };
151
+ const signer = descriptor({
152
+ name: 'signer', config: { key: configField('secret') }, hosts: ['api.example.com'],
153
+ streams: { items: stream({ item: table({ sig: 'string' }, ['sig']) }, { syncModes: ['full'] }) },
154
+ });
155
+ const connector = {
156
+ descriptor: signer,
157
+ implementations: {
158
+ streams: {
159
+ items: defineStream<D, 'items'>('items', async function* (ctx) {
160
+ const sig = await ctx.crypto.hmac('sha256', ctx.secrets.key, 'payload');
161
+ yield ctx.record('item', { sig });
162
+ }),
163
+ },
164
+ },
165
+ };
166
+ const fixture: LoadedFixture = {
167
+ capability: 'streams/items',
168
+ name: 'sign',
169
+ case: { kind: 'pull', stream: 'items', mode: 'incremental', config: {}, secrets: { key: '***' }, expect: { records: { item: 1 } } },
170
+ exchanges: [{ seq: 1, kind: 'crypto', request: { op: 'hmac', algorithm: 'sha256', key: 'ontos:secret:key', data: 'payload', encoding: 'hex' }, value: 'beef' }],
171
+ requests: [],
172
+ };
173
+ const result = await runFixture(createInProcessExecutor(connector), signer, fixture);
174
+ expect(result.error).toBeNull();
175
+ expect(result.ok).toBe(true);
176
+ expect(result.findings).toEqual([]);
177
+ });
178
+ });
179
+
180
+ describe('db', () => {
181
+ const dbExchange = (seq: number, sql: string, params: unknown[], rows: number[], error: { code: string; message: string } | null = null): FixtureExchange => ({
182
+ seq, kind: 'db', sql, params, fields: rows.length > 0 ? [{ name: 'n', dataTypeId: 23 }] : [], rows: rows.map((n) => ({ n })), error,
183
+ });
184
+
185
+ test('a query is answered with every recorded row in one page, matched by SQL and parameters', async () => {
186
+ const host = createReplayHost(pullFixture([dbExchange(1, 'select n from t where k = $1', ['a'], [1, 2, 3])]));
187
+ const miss = await host.call('db.query', { sql: 'select n from t where k = $1', params: ['b'] }).then(() => null, (error: unknown) => (error as FixtureReplayError).code);
188
+ expect(miss).toBe('FIXTURE_EXCHANGE_MISMATCH');
189
+ expect(await host.call('db.query', { sql: 'select n from t where k = $1', params: ['a'] }))
190
+ .toEqual({ cursorId: null, fields: [{ name: 'n', dataTypeId: 23 }], rows: [{ n: 1 }, { n: 2 }, { n: 3 }], done: true });
191
+ expect(host.unused()).toEqual([]);
192
+ });
193
+
194
+ test('a recorded failure: a failed query throws; a cursor that failed midway returns its rows, then db.next throws', async () => {
195
+ const host = createReplayHost(pullFixture([
196
+ dbExchange(1, 'select broken', [], [], { code: 'DB_PROXY_FAILED', message: 'syntax error' }),
197
+ dbExchange(2, 'select n', [], [1], { code: 'DB_PROXY_FAILED', message: 'connection lost' }),
198
+ ]));
199
+ const failed = await host.call('db.query', { sql: 'select broken', params: [] }).then(() => null, (error: unknown) => (error as FixtureReplayError).code);
200
+ expect(failed).toBe('DB_PROXY_FAILED');
201
+ const partial = await host.call('db.query', { sql: 'select n', params: [] }) as { cursorId: string; rows: unknown[]; done: boolean };
202
+ expect(partial.rows).toEqual([{ n: 1 }]);
203
+ expect(partial.done).toBe(false);
204
+ const next = await host.call('db.next', { cursorId: partial.cursorId }).then(() => null, (error: unknown) => (error as FixtureReplayError).message);
205
+ expect(next).toBe('connection lost');
206
+ });
207
+ });