@deepexi/datasense-connector 0.0.0-stage → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +191 -2
- package/package.json +33 -4
- package/scripts/build.ts +188 -0
- package/src/define.ts +267 -0
- package/src/descriptor.ts +116 -0
- package/src/errors.ts +105 -0
- package/src/index.ts +108 -0
- package/src/internal/location.ts +23 -0
- package/src/runtime/guest/bridge.ts +238 -0
- package/src/runtime/guest/cache.ts +43 -0
- package/src/runtime/guest/check.ts +17 -0
- package/src/runtime/guest/crypto.ts +166 -0
- package/src/runtime/guest/ctx.ts +157 -0
- package/src/runtime/guest/db.ts +59 -0
- package/src/runtime/guest/failure.ts +69 -0
- package/src/runtime/guest/http.ts +194 -0
- package/src/runtime/guest/index.ts +102 -0
- package/src/runtime/guest/limits.ts +20 -0
- package/src/runtime/guest/loader.ts +210 -0
- package/src/runtime/guest/log.ts +47 -0
- package/src/runtime/guest/map.ts +45 -0
- package/src/runtime/guest/operation.ts +193 -0
- package/src/runtime/guest/paginate.ts +169 -0
- package/src/runtime/guest/pull.ts +177 -0
- package/src/runtime/guest/push.ts +92 -0
- package/src/runtime/guest/signal.ts +14 -0
- package/src/runtime/guest/suggest.ts +51 -0
- package/src/runtime/guest/time.ts +137 -0
- package/src/runtime/guest-runtime.ts +75 -0
- package/src/runtime/index.ts +53 -0
- package/src/runtime/protocol.ts +557 -0
- package/src/runtime/session.ts +216 -0
- package/src/testing/child.ts +21 -0
- package/src/testing/compliance.ts +343 -0
- package/src/testing/engine.ts +445 -0
- package/src/testing/fixtures.ts +591 -0
- package/src/testing/gateway.ts +482 -0
- package/src/testing/import-guard.ts +221 -0
- package/src/testing/index.ts +43 -0
- package/src/testing/local-db.ts +226 -0
- package/src/testing/mock-external-system.ts +249 -0
- package/src/testing/pinned-http.ts +144 -0
- package/src/testing/recorder.ts +140 -0
- package/src/testing/redaction.ts +94 -0
- package/src/testing/replay.ts +437 -0
- package/src/testing/run-tests.ts +115 -0
- package/src/testing/schema.ts +70 -0
- package/src/testing/source-loader.ts +374 -0
- package/src/testing/types.ts +78 -0
- package/src/types.ts +329 -0
- package/src/validate.ts +267 -0
- package/test/build.test.ts +159 -0
- package/test/define.test-d.ts +182 -0
- package/test/fixtures/acceptance.ts +156 -0
- package/test/fixtures/connectors/http-pull/.ontos/descriptor.json +72 -0
- package/test/fixtures/connectors/http-pull/.ontos/types.ts +61 -0
- package/test/fixtures/connectors/http-pull/package.json +5 -0
- package/test/fixtures/connectors/http-pull/streams/items/index.ts +37 -0
- package/test/fixtures/connectors/http-pull/streams/items/map.ts +12 -0
- package/test/fixtures/connectors/http-pull/streams/items/tests/basic/case.json +15 -0
- package/test/fixtures/connectors/http-pull/streams/items/tests/basic/exchanges.ndjson +2 -0
- package/test/fixtures/connectors/pg-pull/.ontos/descriptor.json +151 -0
- package/test/fixtures/connectors/pg-pull/.ontos/types.ts +62 -0
- package/test/fixtures/connectors/pg-pull/lib/sql.ts +14 -0
- package/test/fixtures/connectors/pg-pull/package.json +5 -0
- package/test/fixtures/connectors/pg-pull/streams/customers/index.ts +30 -0
- package/test/fixtures/connectors/pg-pull/streams/customers/tests/customers-full/case.json +15 -0
- package/test/fixtures/connectors/pg-pull/streams/customers/tests/customers-full/exchanges.ndjson +3 -0
- package/test/fixtures/connectors/pg-pull/streams/orders/index.ts +32 -0
- package/test/fixtures/descriptor.ts +54 -0
- package/test/fixtures/kingdee-example.ts +82 -0
- package/test/fixtures/record-acceptance.ts +32 -0
- package/test/runtime/ctx.test.ts +434 -0
- package/test/runtime/helpers.ts +157 -0
- package/test/runtime/jobs.test.ts +444 -0
- package/test/runtime/operation.test.ts +175 -0
- package/test/runtime/session.test.ts +229 -0
- package/test/testing/acceptance-fixtures.test.ts +69 -0
- package/test/testing/compliance.test.ts +247 -0
- package/test/testing/engine.test.ts +283 -0
- package/test/testing/fixtures.test.ts +221 -0
- package/test/testing/gateway.test.ts +337 -0
- package/test/testing/import-guard.test.ts +45 -0
- package/test/testing/mock-external-system.test.ts +144 -0
- package/test/testing/operation-fixtures.test.ts +160 -0
- package/test/testing/recorder.test.ts +163 -0
- package/test/testing/redaction.test.ts +54 -0
- package/test/testing/replay.test.ts +207 -0
- package/test/testing/run-tests.test.ts +137 -0
- package/test/testing/source-loader.test.ts +220 -0
- package/test/validate-config.test.ts +104 -0
- package/tsconfig.build.json +23 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { createGuestSession, parseChildMessage, parseParentMessage, undeclaredEmitTable } from '../../src/runtime';
|
|
3
|
+
import type { SuggestedColumn } from '../../src';
|
|
4
|
+
import { descriptor, push, table } from '../fixtures/descriptor';
|
|
5
|
+
import {
|
|
6
|
+
bundleConnector, bundleGuest, checkJob, expectFailure, expectResult, pullJob, pushJob, rawConnector, runGuest, suggestJob,
|
|
7
|
+
} from './helpers';
|
|
8
|
+
|
|
9
|
+
const PUSH_DESC = descriptor({ pushes: { default: push({ item: table({ id: 'string' }, ['id']) }) } });
|
|
10
|
+
const pushOnly = (handler = '{ map() {} }', header = ''): string => rawConnector(PUSH_DESC, `pushes: { default: definePush('default', ${handler}) },`, header);
|
|
11
|
+
|
|
12
|
+
describe('模块加载与 SDK 注入', () => {
|
|
13
|
+
test('打包产物(虚拟入口形状)require("@deepexi/datasense-connector") 拿到 guest 内的 definePush / errors', async () => {
|
|
14
|
+
const code = await bundleConnector(`
|
|
15
|
+
import { definePush, errors, SDK_VERSION } from '@deepexi/datasense-connector';
|
|
16
|
+
export const descriptor = ${JSON.stringify(PUSH_DESC)};
|
|
17
|
+
export const implementations = {
|
|
18
|
+
pushes: { default: definePush('default', { map(ctx, req) {
|
|
19
|
+
const error = new errors.RejectedPayload('x');
|
|
20
|
+
ctx.record('item', { id: req.id, code: error.code, sdk: SDK_VERSION });
|
|
21
|
+
} }) },
|
|
22
|
+
};`);
|
|
23
|
+
expect(code).toContain('require("@deepexi/datasense-connector")');
|
|
24
|
+
const { outcome } = await runGuest({ code, job: pushJob([{ requestId: 'r1', body: '{}' }]) });
|
|
25
|
+
const result = expectResult(outcome);
|
|
26
|
+
expect(result).toMatchObject({ kind: 'push', records: [{ requestId: 'r1', table: 'item', row: { id: 'r1', code: 'REJECTED_PAYLOAD', sdk: '3.1.0' } }] });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('require("fs") 加载失败 CONNECTOR_LOAD_FAILED', async () => {
|
|
30
|
+
const code = `require('fs');\n${pushOnly()}`;
|
|
31
|
+
const failure = expectFailure((await runGuest({ code, job: pushJob([{ requestId: 'r1', body: '{}' }]) })).outcome);
|
|
32
|
+
expect(failure.code).toBe('CONNECTOR_LOAD_FAILED');
|
|
33
|
+
expect(failure.message).toContain('cannot require fs');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('顶层访问 process / Bun / fetch / node:vm / WebAssembly / eval 都不可用,会话不崩', async () => {
|
|
37
|
+
const probe = `
|
|
38
|
+
const attempt = (fn) => { try { const v = fn(); return v === undefined ? 'undefined' : typeof v; } catch (e) { return e && e.name ? e.name : 'thrown'; } };
|
|
39
|
+
const probes = {
|
|
40
|
+
process: attempt(() => process), bun: attempt(() => Bun), fetch: attempt(() => fetch),
|
|
41
|
+
vm: attempt(() => require('node:vm')), wasm: attempt(() => WebAssembly), eval: attempt(() => eval('1')),
|
|
42
|
+
fn: attempt(() => Function('return 1')), console: attempt(() => console), shadowRealm: attempt(() => ShadowRealm),
|
|
43
|
+
setTimeout: attempt(() => setTimeout), url: typeof URL,
|
|
44
|
+
hostFn: attempt(() => module.constructor.constructor('return typeof process')()),
|
|
45
|
+
requireCtor: attempt(() => require.constructor('return typeof process')()),
|
|
46
|
+
intl: new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Shanghai', hour: 'numeric', hourCycle: 'h23' }).format(new Date(0)),
|
|
47
|
+
};`;
|
|
48
|
+
const code = pushOnly(`{ map(ctx) { ctx.record('item', { id: '1', ...probes }); } }`, probe);
|
|
49
|
+
const result = expectResult((await runGuest({ code, job: pushJob([{ requestId: 'r1', body: '{}' }]) })).outcome);
|
|
50
|
+
if (result.kind !== 'push') throw new Error('expected push result');
|
|
51
|
+
expect(result.records[0]?.row).toEqual({
|
|
52
|
+
id: '1', process: 'ReferenceError', bun: 'ReferenceError', fetch: 'ReferenceError', vm: 'ConnectorError', wasm: 'ReferenceError',
|
|
53
|
+
eval: 'EvalError', fn: 'EvalError', console: 'ReferenceError', shadowRealm: 'ReferenceError', setTimeout: 'ReferenceError', url: 'undefined',
|
|
54
|
+
hostFn: 'EvalError', requireCtor: 'EvalError', intl: '08',
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('描述的名字或版本与 expect 不符,失败 CONNECTOR_LOAD_FAILED', async () => {
|
|
59
|
+
const code = pushOnly();
|
|
60
|
+
const failure = expectFailure((await runGuest({ code, expect: { name: 'demo', version: '2.0.0' }, job: pushJob([]) })).outcome);
|
|
61
|
+
expect(failure.code).toBe('CONNECTOR_LOAD_FAILED');
|
|
62
|
+
expect(failure.message).toContain('expected demo@2.0.0');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('描述结构不对,失败消息列出问题', async () => {
|
|
66
|
+
const failure = expectFailure((await runGuest({ code: `module.exports = { descriptor: { name: 'demo', version: '1.0.0' }, implementations: {} };`, job: checkJob() })).outcome);
|
|
67
|
+
expect(failure.code).toBe('CONNECTOR_LOAD_FAILED');
|
|
68
|
+
expect(failure.message).toContain('descriptor.config');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('编译错误与加载时抛错都是 CONNECTOR_LOAD_FAILED', async () => {
|
|
72
|
+
const syntax = expectFailure((await runGuest({ code: 'module.exports = {', job: checkJob() })).outcome);
|
|
73
|
+
expect(syntax.code).toBe('CONNECTOR_LOAD_FAILED');
|
|
74
|
+
expect(syntax.message).toContain('failed to compile');
|
|
75
|
+
const thrown = expectFailure((await runGuest({ code: `throw new Error('top level');`, job: checkJob() })).outcome);
|
|
76
|
+
expect(thrown).toMatchObject({ code: 'CONNECTOR_LOAD_FAILED', message: 'connector code failed to load: top level' });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('任何作业都不发 definition / declaration:宿主信息从描述派生', async () => {
|
|
80
|
+
const pushRun = await runGuest({ code: pushOnly(), job: pushJob([{ requestId: 'r1', body: '{}' }]) });
|
|
81
|
+
expect(pushRun.calls).toEqual([]);
|
|
82
|
+
expectResult(pushRun.outcome);
|
|
83
|
+
const checkRun = await runGuest({ code: pushOnly(), job: checkJob() });
|
|
84
|
+
expect(checkRun.calls).toEqual([]);
|
|
85
|
+
expectResult(checkRun.outcome);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('连接器抛错时 stack 的行列号对应打包产物本身', async () => {
|
|
89
|
+
const desc = JSON.stringify({ ...PUSH_DESC, check: true });
|
|
90
|
+
const code = [
|
|
91
|
+
`const { defineCheck, definePush } = require('@deepexi/datasense-connector');`,
|
|
92
|
+
`function explode() {`,
|
|
93
|
+
` throw new Error('kaboom');`,
|
|
94
|
+
`}`,
|
|
95
|
+
`module.exports = { descriptor: ${desc}, implementations: { pushes: { default: definePush('default', { map() {} }) },`,
|
|
96
|
+
` check: defineCheck(() => { explode(); }) } };`,
|
|
97
|
+
].join('\n');
|
|
98
|
+
const failure = expectFailure((await runGuest({ code, job: checkJob() })).outcome);
|
|
99
|
+
expect(failure.code).toBe('FATAL_ERROR');
|
|
100
|
+
expect(failure.message).toBe('kaboom');
|
|
101
|
+
expect(failure.stack).toContain('connector.js:3:');
|
|
102
|
+
expect(failure.stack).toContain('connector.js:6:');
|
|
103
|
+
expect(failure.stack).not.toContain('ontos-guest.js');
|
|
104
|
+
expect(failure.stack).not.toContain(import.meta.dir);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('打包后的 TS 连接器抛错时行号对应打包产物', async () => {
|
|
108
|
+
const code = await bundleConnector(`
|
|
109
|
+
import { defineCheck, definePush } from '@deepexi/datasense-connector';
|
|
110
|
+
export const descriptor = ${JSON.stringify({ ...PUSH_DESC, check: true })};
|
|
111
|
+
export const implementations = {
|
|
112
|
+
check: defineCheck(() => { throw new Error('bundled boom'); }),
|
|
113
|
+
pushes: { default: definePush('default', { map() {} }) },
|
|
114
|
+
};`);
|
|
115
|
+
const failure = expectFailure((await runGuest({ code, job: checkJob() })).outcome);
|
|
116
|
+
const line = code.split('\n').findIndex((text) => text.includes('bundled boom')) + 1;
|
|
117
|
+
expect(line).toBeGreaterThan(0);
|
|
118
|
+
expect(failure.stack).toContain(`connector.js:${line}:`);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('会话边界', () => {
|
|
123
|
+
test('execute 之前的消息与重复的 execute 被忽略;畸形消息被忽略', async () => {
|
|
124
|
+
const guest = await bundleGuest();
|
|
125
|
+
const sent: string[] = [];
|
|
126
|
+
const session = createGuestSession((json) => sent.push(json));
|
|
127
|
+
session.handle('not json');
|
|
128
|
+
session.handle(JSON.stringify({ type: 'reply', id: 1, ok: true, value: 1 }));
|
|
129
|
+
const execute = JSON.stringify({ type: 'execute', guest, code: pushOnly(), expect: { name: 'demo', version: '1.0.0' }, job: pushJob([]) });
|
|
130
|
+
session.handle(execute);
|
|
131
|
+
session.handle(execute);
|
|
132
|
+
await Bun.sleep(50);
|
|
133
|
+
expect(sent.map((json) => JSON.parse(json).type)).toEqual(['result']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('guest 源码不可用时失败 CONNECTOR_LOAD_FAILED', async () => {
|
|
137
|
+
const sent: string[] = [];
|
|
138
|
+
const session = createGuestSession((json) => sent.push(json));
|
|
139
|
+
session.handle(JSON.stringify({ type: 'execute', guest: 'module.exports = {};', code: '', expect: { name: 'demo', version: '1.0.0' }, job: pushJob([]) }));
|
|
140
|
+
expect(JSON.parse(sent[0] ?? '{}')).toMatchObject({ type: 'failure', code: 'CONNECTOR_LOAD_FAILED' });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('改写 Promise.prototype.then 或全局 JSON 拿不到宿主函数,也不影响消息通道', async () => {
|
|
144
|
+
const code = pushOnly(`{ map(ctx, req) {
|
|
145
|
+
const probe = (fn) => { try { return String(fn.constructor.constructor('return typeof process')()); } catch (e) { return e.name; } };
|
|
146
|
+
globalThis.leak = 'none';
|
|
147
|
+
const original = Promise.prototype.then;
|
|
148
|
+
Promise.prototype.then = function (a, b) { if (typeof a === 'function' && globalThis.leak === 'none') globalThis.leak = probe(a); return original.call(this, a, b); };
|
|
149
|
+
JSON.stringify = () => ({ forged: true });
|
|
150
|
+
ctx.record('item', { id: req.id, leak: globalThis.leak });
|
|
151
|
+
} }`);
|
|
152
|
+
const run = await runGuest({ code, job: pushJob([{ requestId: 'r1', body: '{}' }, { requestId: 'r2', body: '{}' }]) });
|
|
153
|
+
const result = expectResult(run.outcome);
|
|
154
|
+
if (result.kind !== 'push') throw new Error('expected push result');
|
|
155
|
+
expect(result.records.map((record) => record.row.id)).toEqual(['r1', 'r2']);
|
|
156
|
+
expect(['none', 'EvalError']).toContain(result.records[1]?.row.leak as string);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('协议解析', () => {
|
|
161
|
+
test('parseParentMessage 校验 execute / reply / cancel', () => {
|
|
162
|
+
expect(parseParentMessage({ type: 'cancel' })).toEqual({ type: 'cancel' });
|
|
163
|
+
expect(parseParentMessage({ type: 'reply', id: 1, ok: false, error: { code: 'EGRESS_RATE_LIMITED', message: 'slow down', retryAfterMs: 50 } }))
|
|
164
|
+
.toEqual({ type: 'reply', id: 1, ok: false, error: { code: 'EGRESS_RATE_LIMITED', message: 'slow down', retryAfterMs: 50 } });
|
|
165
|
+
expect(parseParentMessage({ type: 'reply', id: 0, ok: true, value: 1 })).toBeNull();
|
|
166
|
+
expect(parseParentMessage({ type: 'execute', guest: '', code: '', expect: { name: 'a', version: '1.0.0' }, job: { kind: 'pull', config: {}, secretFields: [] } })).toBeNull();
|
|
167
|
+
expect(parseParentMessage({ type: 'execute', guest: '', code: '', expect: { name: 'a', version: '1.0.0' }, job: pullJob() })).toMatchObject({ type: 'execute', job: { kind: 'pull', stream: 'items' } });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('parseChildMessage 按作业校验:emit 的表、reveal 的字段、方法名、结果的 kind', () => {
|
|
171
|
+
const job = pullJob({ secretFields: ['token'] });
|
|
172
|
+
const batch = { records: [{ table: 'item', row: { id: 1 } }], removals: [], logs: [] };
|
|
173
|
+
expect(parseChildMessage({ type: 'call', id: 1, method: 'emit', payload: batch }, job)).toMatchObject({ method: 'emit' });
|
|
174
|
+
expect(parseChildMessage({ type: 'call', id: 1, method: 'emit', payload: { ...batch, records: [{ table: 'other', row: {} }] } }, job)).toBeNull();
|
|
175
|
+
expect(parseChildMessage({ type: 'call', id: 1, method: 'emit', payload: batch }, checkJob())).toBeNull();
|
|
176
|
+
expect(parseChildMessage({ type: 'call', id: 1, method: 'emit', payload: batch }, suggestJob('items', 'item'))).toBeNull();
|
|
177
|
+
expect(parseChildMessage({ type: 'call', id: 2, method: 'secret.reveal', payload: { field: 'token' } }, job)).toMatchObject({ payload: { field: 'token' } });
|
|
178
|
+
expect(parseChildMessage({ type: 'call', id: 2, method: 'secret.reveal', payload: { field: 'other' } }, job)).toBeNull();
|
|
179
|
+
expect(parseChildMessage({ type: 'call', id: 3, method: 'definition', payload: {} }, job)).toBeNull();
|
|
180
|
+
expect(parseChildMessage({ type: 'call', id: 3, method: 'declaration', payload: {} }, job)).toBeNull();
|
|
181
|
+
expect(parseChildMessage({ type: 'call', id: 4, method: 'exec', payload: {} }, job)).toBeNull();
|
|
182
|
+
expect(parseChildMessage({ type: 'result', result: { kind: 'discover', summary: {}, discover: {}, logs: [] } }, job)).toBeNull();
|
|
183
|
+
expect(parseChildMessage({ type: 'result', result: { kind: 'pull', emittedBatches: 2, logs: [] } }, job)).toEqual({ type: 'result', result: { kind: 'pull', emittedBatches: 2, logs: [] } });
|
|
184
|
+
expect(parseChildMessage({ type: 'failure', code: 'NOPE', message: 'x', logs: [] }, job)).toBeNull();
|
|
185
|
+
expect(parseChildMessage({ type: 'call', id: 5, method: 'http', payload: { method: 'GET', url: 'https://a', headers: {}, query: { a: ['1'] }, body: null, bodyEncoding: 'utf8' } }, job))
|
|
186
|
+
.toMatchObject({ method: 'http' });
|
|
187
|
+
expect(parseChildMessage({ type: 'call', id: 5, method: 'http', payload: { method: 'TRACE', url: 'https://a', headers: {}, query: {}, body: null, bodyEncoding: 'utf8' } }, job)).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('推送结果的 requestId 必须属于本批请求', () => {
|
|
191
|
+
const job = pushJob([{ requestId: 'r1', body: '{}' }]);
|
|
192
|
+
const result = { kind: 'push', records: [{ requestId: 'r2', table: 'item', row: {} }], removals: [], rejected: [], logs: [] };
|
|
193
|
+
expect(parseChildMessage({ type: 'result', result }, job)).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('check / suggest 作业、生效限流、stopped 与 undeclaredEmitTable', () => {
|
|
197
|
+
const check = checkJob();
|
|
198
|
+
expect(parseParentMessage({ type: 'execute', guest: '', code: '', expect: { name: 'a', version: '1.0.0' }, job: check })).toMatchObject({ job: check });
|
|
199
|
+
expect(parseChildMessage({ type: 'result', result: { kind: 'check', ok: true, logs: [] } }, check)).toEqual({ type: 'result', result: { kind: 'check', ok: true, logs: [] } });
|
|
200
|
+
expect(parseChildMessage({ type: 'result', result: { kind: 'check', ok: false, logs: [] } }, check)).toBeNull();
|
|
201
|
+
// discover 作业已删除
|
|
202
|
+
expect(parseParentMessage({ type: 'execute', guest: '', code: '', expect: { name: 'a', version: '1.0.0' }, job: { kind: 'discover', config: {}, secretFields: [] } })).toBeNull();
|
|
203
|
+
|
|
204
|
+
// 生效限流:合法时原样带上,不合法时整条作业不合法
|
|
205
|
+
expect(parseParentMessage({ type: 'execute', guest: '', code: '', expect: { name: 'a', version: '1.0.0' }, job: pullJob({ rateLimit: { perSecond: 5, concurrency: 2 } }) }))
|
|
206
|
+
.toMatchObject({ job: { kind: 'pull', rateLimit: { perSecond: 5, concurrency: 2 } } });
|
|
207
|
+
expect(parseParentMessage({ type: 'execute', guest: '', code: '', expect: { name: 'a', version: '1.0.0' }, job: { ...pullJob(), rateLimit: { perSecond: 5, concurrency: 0 } } })).toBeNull();
|
|
208
|
+
|
|
209
|
+
// suggest:结果只认 { kind: suggest, columns },列的结构重建
|
|
210
|
+
const suggest = suggestJob('customers', 'customers');
|
|
211
|
+
expect(parseParentMessage({ type: 'execute', guest: '', code: '', expect: { name: 'a', version: '1.0.0' }, job: suggest })).toMatchObject({ job: suggest });
|
|
212
|
+
const columns: SuggestedColumn[] = [{ name: 'vip_level', type: 'string', title: 'VIP' }, { name: 'credit', type: 'decimal', precision: 12, scale: 2, title: 'Credit', description: 'x' }];
|
|
213
|
+
expect(parseChildMessage({ type: 'result', result: { kind: 'suggest', columns, logs: [] } }, suggest)).toEqual({ type: 'result', result: { kind: 'suggest', columns, logs: [] } });
|
|
214
|
+
expect(parseChildMessage({ type: 'result', result: { kind: 'suggest', columns: [{ name: 'x', type: 'text', title: 'x' }], logs: [] } }, suggest)).toBeNull();
|
|
215
|
+
expect(parseChildMessage({ type: 'result', result: { kind: 'pull', emittedBatches: 0, logs: [] } }, suggest)).toBeNull();
|
|
216
|
+
|
|
217
|
+
// undeclaredEmitTable:只在 emit 写了作业表以外的表时给出表名(宿主按 SCHEMA_ERROR 结束),其他不合法消息为 null
|
|
218
|
+
const stray = { type: 'call', id: 5, method: 'emit', payload: { records: [{ table: 'item', row: {} }], removals: [{ table: 'ghost', key: {} }], logs: [] } };
|
|
219
|
+
expect(undeclaredEmitTable(stray, pullJob({ tables: ['item'] }))).toBe('ghost');
|
|
220
|
+
expect(undeclaredEmitTable(stray, pullJob({ tables: ['item', 'ghost'] }))).toBeNull();
|
|
221
|
+
expect(undeclaredEmitTable({ type: 'call', id: 6, method: 'http', payload: {} }, pullJob())).toBeNull();
|
|
222
|
+
|
|
223
|
+
// stopped
|
|
224
|
+
const job = pullJob();
|
|
225
|
+
expect(parseChildMessage({ type: 'result', result: { kind: 'pull', emittedBatches: 1, stopped: true, logs: [] } }, job))
|
|
226
|
+
.toEqual({ type: 'result', result: { kind: 'pull', emittedBatches: 1, stopped: true, logs: [] } });
|
|
227
|
+
expect(parseChildMessage({ type: 'result', result: { kind: 'pull', emittedBatches: 1, stopped: 'yes', logs: [] } }, job)).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 两个验收连接器(`test/fixtures/connectors/{http-pull,pg-pull}`)各录一个用例(在数据流目录的 `tests/` 下),`runFixtures` 回放都 `ok: true`;
|
|
3
|
+
* 重新录制得到与已提交文件逐字节相同的内容(pg-pull 需要本机 :15432 的 Postgres,没有时跳过重录)。
|
|
4
|
+
*/
|
|
5
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import ts from 'typescript';
|
|
8
|
+
import type { ConnectorDescriptor } from '../../src';
|
|
9
|
+
import {
|
|
10
|
+
createInProcessExecutor, createVmExecutor, loadLocalConnector, readFixtureDir, runFixtures, type FixtureCaseResult,
|
|
11
|
+
} from '../../src/testing';
|
|
12
|
+
import {
|
|
13
|
+
dropPgSchema, HTTP_PULL_DIR, localPgUnavailable, PG_PULL_DIR, preparePgSchema, recordHttpPull, recordPgPull,
|
|
14
|
+
} from '../fixtures/acceptance';
|
|
15
|
+
|
|
16
|
+
const pgUnavailable = await localPgUnavailable();
|
|
17
|
+
if (pgUnavailable) console.warn(`[acceptance-fixtures] 本机 :15432 没有 Postgres,跳过 pg_pull 重录:${pgUnavailable}`);
|
|
18
|
+
|
|
19
|
+
const CASES = [
|
|
20
|
+
{ connector: 'http_pull', source: HTTP_PULL_DIR, name: 'basic', stream: 'items', table: 'items' },
|
|
21
|
+
{ connector: 'pg_pull', source: PG_PULL_DIR, name: 'customers-full', stream: 'customers', table: 'customers' },
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
function expectPassed(results: FixtureCaseResult[], name: string, stream: string, table: string): void {
|
|
25
|
+
expect(results).toHaveLength(1);
|
|
26
|
+
const [result] = results;
|
|
27
|
+
expect(result?.error).toBeNull();
|
|
28
|
+
expect(result?.findings).toEqual([]);
|
|
29
|
+
expect(result?.ok).toBe(true);
|
|
30
|
+
expect(result?.name).toBe(name);
|
|
31
|
+
expect(result?.capability).toBe(`streams/${stream}`);
|
|
32
|
+
expect(result?.stats.records).toEqual({ [table]: 3 });
|
|
33
|
+
expect(result?.stats.checkpoints).toBe(2);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe.each(CASES)('$connector fixture', ({ source, name, stream, table }) => {
|
|
37
|
+
test('replays ok in the vm (CLI path)', async () => {
|
|
38
|
+
const connector = await loadLocalConnector(source, { typescript: ts });
|
|
39
|
+
expectPassed(await runFixtures(createVmExecutor(connector), connector.descriptor, connector.testFiles), name, stream, table);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('replays ok in process (vitest path)', async () => {
|
|
43
|
+
const descriptor = JSON.parse(await Bun.file(path.join(source, '.ontos/descriptor.json')).text()) as ConnectorDescriptor;
|
|
44
|
+
const streams: Record<string, unknown> = {};
|
|
45
|
+
for (const name of Object.keys(descriptor.streams)) streams[name] = ((await import(path.join(source, 'streams', name, 'index.ts'))) as { default: unknown }).default;
|
|
46
|
+
const executor = createInProcessExecutor({ descriptor, implementations: { streams } });
|
|
47
|
+
expectPassed(await runFixtures(executor, descriptor, await readFixtureDir(source)), name, stream, table);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('re-recording', () => {
|
|
52
|
+
afterAll(async () => {
|
|
53
|
+
await dropPgSchema();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('http_pull: recording again gives the committed files byte for byte', async () => {
|
|
57
|
+
const recorded = await recordHttpPull();
|
|
58
|
+
expect(recorded.result.error).toBeNull();
|
|
59
|
+
expect(recorded.files).toEqual(await readFixtureDir(HTTP_PULL_DIR));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// 本机没有 :15432 的 Postgres 时跳过
|
|
63
|
+
test.skipIf(Boolean(pgUnavailable))('pg_pull: recording again against localhost:15432 gives the committed files byte for byte', async () => {
|
|
64
|
+
expect(await preparePgSchema()).toBe(true);
|
|
65
|
+
const recorded = await recordPgPull();
|
|
66
|
+
expect(recorded.result.error).toBeNull();
|
|
67
|
+
expect(recorded.files).toEqual(await readFixtureDir(PG_PULL_DIR));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 合规检查与结构推断:主键重复、列类型、未声明表与字段、断点单调、全量可重复、期望、ok 的判定。
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
import { definePush, defineStream, defineSuggestColumns, errors } from '../../src';
|
|
6
|
+
import {
|
|
7
|
+
checkCompliance, createInProcessExecutor, inferRecordSchema, runFixture, type ComplianceTable, type LoadedFixture, type LocalRunResult,
|
|
8
|
+
} from '../../src/testing';
|
|
9
|
+
import { descriptor, push, stream, table } from '../fixtures/descriptor';
|
|
10
|
+
|
|
11
|
+
const ORDERS: Record<string, ComplianceTable> = {
|
|
12
|
+
orders: { columns: { id: { type: 'string' }, amount: { type: 'integer' }, placed_at: { type: 'instant' } }, primaryKey: ['id'], extensible: false },
|
|
13
|
+
};
|
|
14
|
+
const EVENTS: Record<string, ComplianceTable> = { events: { columns: { id: { type: 'string' } }, primaryKey: ['id'], extensible: false } };
|
|
15
|
+
|
|
16
|
+
function result(overrides: Partial<LocalRunResult> = {}): LocalRunResult {
|
|
17
|
+
return { records: [], removals: [], checkpoints: [], logs: [], rejected: [], stopped: false, error: null, ...overrides };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const order = (row: Record<string, unknown>) => ({ table: 'orders', row });
|
|
21
|
+
const codes = (findings: Array<{ code: string }>) => findings.map((finding) => finding.code);
|
|
22
|
+
|
|
23
|
+
describe('records against the tables', () => {
|
|
24
|
+
test('two records with the same primary key give PRIMARY_KEY_DUPLICATE for that table', () => {
|
|
25
|
+
const findings = checkCompliance({
|
|
26
|
+
tables: ORDERS,
|
|
27
|
+
result: result({ records: [order({ id: 'a', amount: 1 }), order({ id: 'b', amount: 2 }), order({ id: 'a', amount: 3 })] }),
|
|
28
|
+
});
|
|
29
|
+
expect(findings).toHaveLength(1);
|
|
30
|
+
expect(findings[0]).toMatchObject({ code: 'PRIMARY_KEY_DUPLICATE', table: 'orders', detail: { count: 1, sample: { id: 'a' } } });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('a value of the wrong type gives RECORD_SCHEMA_MISMATCH', () => {
|
|
34
|
+
const wrongType = checkCompliance({ tables: ORDERS, result: result({ records: [order({ id: 'a', amount: 'x' })] }) });
|
|
35
|
+
expect(wrongType).toHaveLength(1);
|
|
36
|
+
expect(wrongType[0]).toMatchObject({ code: 'RECORD_SCHEMA_MISMATCH', table: 'orders', detail: { column: 'amount', sample: 'x' } });
|
|
37
|
+
|
|
38
|
+
const fine = checkCompliance({
|
|
39
|
+
tables: ORDERS,
|
|
40
|
+
result: result({ records: [order({ id: 'a', amount: '12', placed_at: '2026-10-01T00:00:00+08:00' }), order({ id: 'b', amount: null })] }),
|
|
41
|
+
});
|
|
42
|
+
expect(fine).toEqual([]);
|
|
43
|
+
|
|
44
|
+
const naive = checkCompliance({ tables: ORDERS, result: result({ records: [order({ id: 'a', placed_at: '2026-10-01T00:00:00' })] }) });
|
|
45
|
+
expect(codes(naive)).toEqual(['RECORD_SCHEMA_MISMATCH']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('the same problem on many records is reported once with a count', () => {
|
|
49
|
+
const findings = checkCompliance({
|
|
50
|
+
tables: ORDERS,
|
|
51
|
+
result: result({ records: [order({ id: 'a', amount: 'x' }), order({ id: 'b', amount: 'y' }), order({ id: 'c', amount: 1.5 })] }),
|
|
52
|
+
});
|
|
53
|
+
expect(findings).toHaveLength(1);
|
|
54
|
+
expect(findings[0]?.detail).toMatchObject({ count: 3, sample: 'x' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('undeclared fields on a non-extensible table give one UNDECLARED_FIELDS first, listing every field sorted', () => {
|
|
58
|
+
const findings = checkCompliance({
|
|
59
|
+
tables: ORDERS, result: result({ records: [order({ id: 'a', note: 'n', amount: 'x' }), order({ id: 'b', zeta: 1, alpha: 2 })] }),
|
|
60
|
+
});
|
|
61
|
+
expect(codes(findings)).toEqual(['UNDECLARED_FIELDS', 'RECORD_SCHEMA_MISMATCH']);
|
|
62
|
+
expect(findings[0]).toMatchObject({ table: 'orders', detail: { fields: ['alpha', 'note', 'zeta'] } });
|
|
63
|
+
expect(findings[0]?.message).toContain('alpha, note, zeta');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('extra fields on an extensible table are not a finding', () => {
|
|
67
|
+
const tables = { orders: { ...ORDERS.orders!, extensible: true } };
|
|
68
|
+
expect(checkCompliance({ tables, result: result({ records: [order({ id: 'a', vip_level: 'gold' })] }) })).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a record for a table outside the stream gives UNDECLARED_TABLE', () => {
|
|
72
|
+
const findings = checkCompliance({
|
|
73
|
+
tables: ORDERS,
|
|
74
|
+
result: result({ records: [{ table: 'events', row: { id: 'e' } }], removals: [{ table: 'ghost', key: { id: 'g' } }] }),
|
|
75
|
+
});
|
|
76
|
+
expect(findings.map((finding) => [finding.code, finding.table])).toEqual([['UNDECLARED_TABLE', 'events'], ['UNDECLARED_TABLE', 'ghost']]);
|
|
77
|
+
// 推送范围:events 是该推送的表
|
|
78
|
+
expect(checkCompliance({ tables: EVENTS, result: result({ records: [{ table: 'events', row: { id: 'e' } }] }) })).toEqual([]);
|
|
79
|
+
// 没有表结构时跳过依赖表结构的检查
|
|
80
|
+
expect(checkCompliance({ tables: null, result: result({ records: [{ table: 'events', row: { x: 1 } }] }) })).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('removal keys are checked against the primary key', () => {
|
|
84
|
+
const findings = checkCompliance({ tables: ORDERS, result: result({ removals: [{ table: 'orders', key: { id: 'a', amount: 1 } }] }) });
|
|
85
|
+
expect(findings).toHaveLength(1);
|
|
86
|
+
expect(findings[0]).toMatchObject({ code: 'RECORD_SCHEMA_MISMATCH', detail: { column: 'amount' } });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('checkpoints', () => {
|
|
91
|
+
const run = (states: unknown[]) => checkCompliance({ tables: null, result: result({ checkpoints: states.map((state, index) => ({ seq: index + 1, state })) }) });
|
|
92
|
+
|
|
93
|
+
test('a number going backwards is CHECKPOINT_NOT_MONOTONIC', () => {
|
|
94
|
+
const findings = run([{ cursor: 5 }, { cursor: 3 }]);
|
|
95
|
+
expect(codes(findings)).toEqual(['CHECKPOINT_NOT_MONOTONIC']);
|
|
96
|
+
expect(findings[0]?.detail).toEqual({ field: 'cursor', previous: 5, next: 3, seq: 2 });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('ISO times compare by instant: 2026-10-02Z then 2026-10-01+08:00 goes backwards', () => {
|
|
100
|
+
expect(codes(run([{ updatedAt: '2026-10-02T00:00:00Z' }, { updatedAt: '2026-10-01T00:00:00+08:00' }]))).toEqual(['CHECKPOINT_NOT_MONOTONIC']);
|
|
101
|
+
// 同一时刻的不同写法不算倒退
|
|
102
|
+
expect(run([{ updatedAt: '2026-10-01T00:00:00+08:00' }, { updatedAt: '2026-09-30T16:00:00.000Z' }])).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('other strings are not compared: token b then a is fine; equal and increasing values are fine', () => {
|
|
106
|
+
expect(run([{ token: 'b' }, { token: 'a' }])).toEqual([]);
|
|
107
|
+
expect(run([{ cursor: 1, at: '2026-10-01' }, { cursor: 1, at: '2026-10-02' }, { cursor: 2 }])).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('runFixture', () => {
|
|
112
|
+
const DESCRIPTOR = descriptor({
|
|
113
|
+
name: 'drift', hosts: ['api.example.com'],
|
|
114
|
+
streams: { items: stream({ item: table({ id: 'string', n: 'integer' }, ['id']) }) },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/** 每次运行产出不同的记录(第二遍多一条)。 */
|
|
118
|
+
function drifting() {
|
|
119
|
+
let runs = 0;
|
|
120
|
+
return createInProcessExecutor({
|
|
121
|
+
descriptor: DESCRIPTOR,
|
|
122
|
+
implementations: {
|
|
123
|
+
streams: {
|
|
124
|
+
items: defineStream('items', async function* (ctx) {
|
|
125
|
+
runs += 1;
|
|
126
|
+
for (let index = 0; index < runs + 1; index += 1) yield ctx.record('item', { id: `r${index}`, n: index });
|
|
127
|
+
}),
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const fixture = (mode: 'full' | 'incremental', expect?: Record<string, unknown>): LoadedFixture => ({
|
|
134
|
+
capability: 'streams/items', name: 'drift', case: { kind: 'pull', stream: 'items', mode, config: {}, ...(expect ? { expect } : {}) }, exchanges: [], requests: [],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('two full reads with different records give FULL_READ_NOT_REPEATABLE; incremental mode skips the check', async () => {
|
|
138
|
+
const full = await runFixture(drifting(), DESCRIPTOR, fixture('full'));
|
|
139
|
+
expect(codes(full.findings)).toEqual(['FULL_READ_NOT_REPEATABLE']);
|
|
140
|
+
expect(full.findings[0]?.detail).toMatchObject({ firstCount: 2, secondCount: 3 });
|
|
141
|
+
expect(full.ok).toBe(false);
|
|
142
|
+
|
|
143
|
+
const incremental = await runFixture(drifting(), DESCRIPTOR, fixture('incremental'));
|
|
144
|
+
expect(incremental.findings).toEqual([]);
|
|
145
|
+
expect(incremental.ok).toBe(true);
|
|
146
|
+
expect(incremental.stats.records).toEqual({ item: 2 });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('expect.records { item: 3 } with 2 records gives EXPECTATION_FAILED and ok false', async () => {
|
|
150
|
+
const failed = await runFixture(drifting(), DESCRIPTOR, fixture('incremental', { records: { item: 3 } }));
|
|
151
|
+
expect(codes(failed.findings)).toEqual(['EXPECTATION_FAILED']);
|
|
152
|
+
expect(failed.findings[0]?.message).toContain('item: expected 3, got 2');
|
|
153
|
+
expect(failed.ok).toBe(false);
|
|
154
|
+
|
|
155
|
+
const passed = await runFixture(drifting(), DESCRIPTOR, fixture('incremental', { records: { item: 2 }, checkpoints: 0 }));
|
|
156
|
+
expect(passed.ok).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('any finding other than FIXTURE_EXCHANGE_UNUSED fails the case; undeclared fields fail with UNDECLARED_FIELDS first', async () => {
|
|
160
|
+
const executor = createInProcessExecutor({
|
|
161
|
+
descriptor: DESCRIPTOR,
|
|
162
|
+
implementations: {
|
|
163
|
+
streams: { items: defineStream('items', async function* (ctx) { yield ctx.record('item', { id: 'same', n: 1, extra: 'x' }); yield ctx.record('item', { id: 'same', n: 2 }); }) },
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
const outcome = await runFixture(executor, DESCRIPTOR, fixture('incremental'));
|
|
167
|
+
expect(codes(outcome.findings)).toEqual(['UNDECLARED_FIELDS', 'PRIMARY_KEY_DUPLICATE']);
|
|
168
|
+
expect(outcome.ok).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('a capability missing from the descriptor gives CAPABILITY_UNKNOWN', async () => {
|
|
172
|
+
const outcome = await runFixture(drifting(), DESCRIPTOR, { ...fixture('full'), capability: 'streams/ghost', case: { kind: 'pull', stream: 'ghost', mode: 'full', config: {} } });
|
|
173
|
+
expect(outcome.ok).toBe(false);
|
|
174
|
+
expect(outcome.error?.code).toBe('CAPABILITY_UNKNOWN');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('push cases replay requests.ndjson against the push tables', async () => {
|
|
178
|
+
const desc = descriptor({ name: 'pusher', pushes: { default: push({ event: table({ id: 'string' }, ['id']) }) } });
|
|
179
|
+
const pusher = createInProcessExecutor({
|
|
180
|
+
descriptor: desc,
|
|
181
|
+
implementations: {
|
|
182
|
+
pushes: {
|
|
183
|
+
default: definePush('default', {
|
|
184
|
+
map(ctx, request) {
|
|
185
|
+
const body = JSON.parse(request.body) as { id?: string };
|
|
186
|
+
if (!body.id) throw new errors.RejectedPayload('no id');
|
|
187
|
+
ctx.record(body.id === 'stray' ? 'other' : 'event', { id: body.id });
|
|
188
|
+
},
|
|
189
|
+
}),
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
const requests = ['{"id":"a"}', '{}', '{"id":"stray"}'].map((body, index) => ({ requestId: `r${index}`, receivedAt: '2026-10-25T00:00:00.000Z', headers: {}, body }));
|
|
194
|
+
const outcome = await runFixture(pusher, desc, {
|
|
195
|
+
capability: 'pushes/default', name: 'hook', case: { kind: 'push', push: 'default', config: {}, expect: { rejected: 1 } }, exchanges: [], requests,
|
|
196
|
+
});
|
|
197
|
+
expect(outcome.error).toBeNull();
|
|
198
|
+
expect(outcome.stats).toEqual({ records: { event: 1, other: 1 }, removals: {}, checkpoints: 0, rejected: 1 });
|
|
199
|
+
expect(codes(outcome.findings)).toEqual(['UNDECLARED_TABLE']);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('suggest cases compare the suggestions with expect.columns, ignoring order', async () => {
|
|
203
|
+
const desc = descriptor({
|
|
204
|
+
name: 'crm',
|
|
205
|
+
streams: { customers: stream({ customers: table({ id: 'string' }, ['id'], { extensible: true }) }, { suggestColumns: true }) },
|
|
206
|
+
});
|
|
207
|
+
const executor = createInProcessExecutor({
|
|
208
|
+
descriptor: desc,
|
|
209
|
+
implementations: {
|
|
210
|
+
streams: { customers: defineStream('customers', async function* () {}) },
|
|
211
|
+
suggestColumns: {
|
|
212
|
+
customers: defineSuggestColumns('customers', async () => [
|
|
213
|
+
{ name: 'vip_level', type: 'string', title: 'VIP level' },
|
|
214
|
+
{ name: 'id', type: 'string', title: 'duplicate base column' },
|
|
215
|
+
{ name: 'credit', type: 'decimal', precision: 12, scale: 2, title: 'Credit' },
|
|
216
|
+
]),
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
const suggestCase = (columns: unknown[]): LoadedFixture => ({
|
|
221
|
+
capability: 'streams/customers', name: 'fields', exchanges: [], requests: [],
|
|
222
|
+
case: { kind: 'suggest', stream: 'customers', table: 'customers', config: {}, expect: { columns: columns as never } },
|
|
223
|
+
});
|
|
224
|
+
const passed = await runFixture(executor, desc, suggestCase([
|
|
225
|
+
{ name: 'credit', type: 'decimal', precision: 12, scale: 2, title: 'Credit' },
|
|
226
|
+
{ name: 'vip_level', type: 'string', title: 'VIP level' },
|
|
227
|
+
]));
|
|
228
|
+
expect(passed).toMatchObject({ ok: true, kind: 'suggest', error: null });
|
|
229
|
+
const failed = await runFixture(executor, desc, suggestCase([{ name: 'vip_level', type: 'string', title: 'VIP level' }]));
|
|
230
|
+
expect(failed.ok).toBe(false);
|
|
231
|
+
expect(codes(failed.findings)).toEqual(['EXPECTATION_FAILED']);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('inferRecordSchema', () => {
|
|
236
|
+
test('nulls are ignored; ISO date-times are timestamps', () => {
|
|
237
|
+
expect(inferRecordSchema([{ a: 1 }, { a: null, b: '2026-10-01T00:00:00Z' }])).toEqual({ a: 'number', b: 'timestamp' });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('all-null columns, mixed strings, objects and mixed kinds', () => {
|
|
241
|
+
expect(inferRecordSchema([
|
|
242
|
+
{ n: null, s: '2026-10-01T00:00:00Z', o: { k: 1 }, m: 1, b: true, d: '2026-10-01' },
|
|
243
|
+
{ n: null, s: 'plain', o: [1], m: 'x', b: false, d: '2026-10-02' },
|
|
244
|
+
])).toEqual({ n: 'null', s: 'string', o: 'json', m: 'json', b: 'boolean', d: 'string' });
|
|
245
|
+
expect(inferRecordSchema([])).toEqual({});
|
|
246
|
+
});
|
|
247
|
+
});
|