@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.
- package/README.md +158 -139
- package/package.json +19 -28
- 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/{runtime/guest/limits.d.ts → src/runtime/guest/limits.ts} +12 -12
- 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
- package/cjs/index.cjs +0 -1015
- package/cjs/runtime.cjs +0 -752
- package/cjs/testing.cjs +0 -3591
- package/errors.d.ts +0 -17
- package/errors.js +0 -53
- package/guest.cjs +0 -2659
- package/index.d.ts +0 -13
- package/index.js +0 -7
- package/internal/location.d.ts +0 -6
- package/internal/location.js +0 -8
- package/presentation.d.ts +0 -38
- package/presentation.js +0 -268
- package/runtime/guest/bridge.d.ts +0 -53
- package/runtime/guest/bridge.js +0 -187
- package/runtime/guest/cache.d.ts +0 -3
- package/runtime/guest/cache.js +0 -42
- package/runtime/guest/crypto.d.ts +0 -13
- package/runtime/guest/crypto.js +0 -177
- package/runtime/guest/ctx.d.ts +0 -30
- package/runtime/guest/ctx.js +0 -124
- package/runtime/guest/db.d.ts +0 -6
- package/runtime/guest/db.js +0 -59
- package/runtime/guest/discover.d.ts +0 -29
- package/runtime/guest/discover.js +0 -51
- package/runtime/guest/failure.d.ts +0 -24
- package/runtime/guest/failure.js +0 -63
- package/runtime/guest/http.d.ts +0 -4
- package/runtime/guest/http.js +0 -204
- package/runtime/guest/index.d.ts +0 -12
- package/runtime/guest/index.js +0 -89
- package/runtime/guest/limits.js +0 -12
- package/runtime/guest/loader.d.ts +0 -15
- package/runtime/guest/loader.js +0 -42
- package/runtime/guest/log.d.ts +0 -10
- package/runtime/guest/log.js +0 -36
- package/runtime/guest/map.d.ts +0 -4
- package/runtime/guest/map.js +0 -45
- package/runtime/guest/paginate.d.ts +0 -6
- package/runtime/guest/paginate.js +0 -180
- package/runtime/guest/pull.d.ts +0 -12
- package/runtime/guest/pull.js +0 -150
- package/runtime/guest/push.d.ts +0 -18
- package/runtime/guest/push.js +0 -80
- package/runtime/guest/signal.d.ts +0 -3
- package/runtime/guest/signal.js +0 -12
- package/runtime/guest/time.d.ts +0 -5
- package/runtime/guest/time.js +0 -127
- package/runtime/guest-runtime.d.ts +0 -7
- package/runtime/guest-runtime.js +0 -51
- package/runtime/index.d.ts +0 -18
- package/runtime/index.js +0 -3
- package/runtime/protocol.d.ts +0 -276
- package/runtime/protocol.js +0 -409
- package/runtime/session.d.ts +0 -12
- package/runtime/session.js +0 -191
- package/t.d.ts +0 -18
- package/t.js +0 -26
- package/testing/child.cjs +0 -350
- package/testing/child.d.ts +0 -1
- package/testing/child.js +0 -12
- package/testing/compliance.d.ts +0 -41
- package/testing/compliance.js +0 -291
- package/testing/engine.d.ts +0 -66
- package/testing/engine.js +0 -339
- package/testing/fixtures.d.ts +0 -145
- package/testing/fixtures.js +0 -407
- package/testing/gateway.d.ts +0 -65
- package/testing/gateway.js +0 -402
- package/testing/import-guard.d.ts +0 -57
- package/testing/import-guard.js +0 -187
- package/testing/index.d.ts +0 -33
- package/testing/index.js +0 -10
- package/testing/local-db.d.ts +0 -27
- package/testing/local-db.js +0 -181
- package/testing/pinned-http.d.ts +0 -14
- package/testing/pinned-http.js +0 -125
- package/testing/recorder.d.ts +0 -15
- package/testing/recorder.js +0 -116
- package/testing/redaction.d.ts +0 -28
- package/testing/redaction.js +0 -67
- package/testing/replay.d.ts +0 -50
- package/testing/replay.js +0 -313
- package/testing/schema.d.ts +0 -13
- package/testing/schema.js +0 -65
- package/testing/source-loader.d.ts +0 -19
- package/testing/source-loader.js +0 -238
- package/testing/types.d.ts +0 -79
- package/testing/types.js +0 -18
- package/types.d.ts +0 -302
- package/types.js +0 -13
- package/validate.d.ts +0 -78
- package/validate.js +0 -597
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `runTests`:按能力目录的 `tests/` 跑用例并统计覆盖;未声明字段让用例失败(可扩展表除外);建议列用例照常运行但不计入覆盖。
|
|
3
|
+
* 连接器项目写在临时目录里,描述手写进 `.ontos/descriptor.json`,经 vm 子进程执行(与 CLI 同一路径)。
|
|
4
|
+
*/
|
|
5
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
6
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import ts from 'typescript';
|
|
10
|
+
import { loadLocalConnector, operationCaseGaps, runTests, testCoverage } from '../../src/testing';
|
|
11
|
+
import { configField, descriptor, operation, stream, table, valueField } from '../fixtures/descriptor';
|
|
12
|
+
|
|
13
|
+
const DESCRIPTOR = descriptor({
|
|
14
|
+
name: 'crm',
|
|
15
|
+
streams: {
|
|
16
|
+
orders: stream({ orders: table({ id: 'string', amount: 'integer' }, ['id']) }, {
|
|
17
|
+
syncModes: ['full'], params: { withFoo: configField('boolean', { optional: true }) },
|
|
18
|
+
}),
|
|
19
|
+
customers: stream({ customers: table({ id: 'string' }, ['id'], { extensible: true }) }, { syncModes: ['full'], suggestColumns: true }),
|
|
20
|
+
},
|
|
21
|
+
operations: {
|
|
22
|
+
create_followup: operation({ customerId: valueField('string') }, { followupId: valueField('string') }),
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const SOURCES: Record<string, string> = {
|
|
27
|
+
'package.json': JSON.stringify({ dependencies: { '@deepexi/datasense-connector': '^3.0.0' } }),
|
|
28
|
+
'.ontos/descriptor.json': JSON.stringify(DESCRIPTOR),
|
|
29
|
+
'streams/orders/index.ts': `import { defineStream } from '@deepexi/datasense-connector';
|
|
30
|
+
export default defineStream('orders', async function* (ctx, input) {
|
|
31
|
+
yield ctx.record('orders', input.streamConfig.withFoo ? { id: 'o1', amount: 1, foo: 'x', bar: 2 } : { id: 'o1', amount: 1 });
|
|
32
|
+
});
|
|
33
|
+
`,
|
|
34
|
+
'streams/customers/index.ts': `import { defineStream } from '@deepexi/datasense-connector';
|
|
35
|
+
export default defineStream('customers', async function* (ctx) {
|
|
36
|
+
yield ctx.record('customers', { id: 'c1', vip_level: 'gold' });
|
|
37
|
+
});
|
|
38
|
+
`,
|
|
39
|
+
'streams/customers/suggest-columns.ts': `import { defineSuggestColumns } from '@deepexi/datasense-connector';
|
|
40
|
+
export default defineSuggestColumns('customers', async () => [{ name: 'vip_level', type: 'string', title: 'VIP level' }]);
|
|
41
|
+
`,
|
|
42
|
+
'operations/create_followup/index.ts': `import { defineOperation, errors } from '@deepexi/datasense-connector';
|
|
43
|
+
export default defineOperation('create_followup', {
|
|
44
|
+
async run(ctx, params) {
|
|
45
|
+
if (params.customerId === 'blocked') throw new errors.RejectedError('customer is blocked', { code: 'BLOCKED' });
|
|
46
|
+
return { followupId: 'f-' + params.customerId };
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
`,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const CASES: Record<string, Record<string, unknown>> = {
|
|
53
|
+
'streams/orders/tests/first-page': { mode: 'full', config: {}, expect: { records: { orders: 1 } } },
|
|
54
|
+
'streams/orders/tests/with-foo': { mode: 'full', config: {}, streamConfig: { withFoo: true } },
|
|
55
|
+
'streams/customers/tests/extra': { mode: 'full', config: {}, expect: { records: { customers: 1 } } },
|
|
56
|
+
'streams/customers/tests/s1': { kind: 'suggest', table: 'customers', config: {}, expect: { columns: [{ name: 'vip_level', type: 'string', title: 'VIP level' }] } },
|
|
57
|
+
'streams/customers/tests/s2': { kind: 'suggest', table: 'customers', config: {}, expect: { columns: [{ name: 'tier', type: 'string', title: 'Tier' }] } },
|
|
58
|
+
'operations/create_followup/tests/ok': { params: { customerId: 'c1' }, idempotencyKey: 'k1', config: {}, expect: { ok: { followupId: 'f-c1' } } },
|
|
59
|
+
'operations/create_followup/tests/rejected': { params: { customerId: 'blocked' }, idempotencyKey: 'k2', config: {}, expect: { error: 'REJECTED' } },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const temps: string[] = [];
|
|
63
|
+
afterAll(async () => {
|
|
64
|
+
await Promise.all(temps.map((dir) => rm(dir, { recursive: true, force: true })));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** 写一个带指定用例的项目。 */
|
|
68
|
+
async function project(cases: string[]): Promise<string> {
|
|
69
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'sdk-run-tests-'));
|
|
70
|
+
temps.push(dir);
|
|
71
|
+
const write = async (file: string, content: string) => {
|
|
72
|
+
await mkdir(path.dirname(path.join(dir, file)), { recursive: true });
|
|
73
|
+
await writeFile(path.join(dir, file), content);
|
|
74
|
+
};
|
|
75
|
+
for (const [file, content] of Object.entries(SOURCES)) await write(file, content);
|
|
76
|
+
for (const name of cases) await write(`${name}/case.json`, JSON.stringify(CASES[name]));
|
|
77
|
+
return dir;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const load = async (cases: string[]) => loadLocalConnector(await project(cases), { typescript: ts });
|
|
81
|
+
|
|
82
|
+
describe('runTests', () => {
|
|
83
|
+
test('coverage: orders and create_followup are covered; customers with only a suggest case is uncovered; ok is false', async () => {
|
|
84
|
+
const report = await runTests(await load(['streams/orders/tests/first-page', 'operations/create_followup/tests/ok', 'streams/customers/tests/s1']));
|
|
85
|
+
expect(report.covered).toEqual(['streams/orders', 'operations/create_followup']);
|
|
86
|
+
expect(report.uncovered).toEqual(['streams/customers']);
|
|
87
|
+
expect(report.ok).toBe(false);
|
|
88
|
+
// 每个用例都通过:建议列用例比对了 expect.columns
|
|
89
|
+
expect(report.cases.map((item) => [`${item.capability}/${item.name}`, item.ok])).toEqual([
|
|
90
|
+
['operations/create_followup/ok', true],
|
|
91
|
+
['streams/customers/s1', true],
|
|
92
|
+
['streams/orders/first-page', true],
|
|
93
|
+
]);
|
|
94
|
+
}, 30_000);
|
|
95
|
+
|
|
96
|
+
test('undeclared fields on a non-extensible table fail the case with UNDECLARED_FIELDS listing the fields', async () => {
|
|
97
|
+
const report = await runTests(await load(['streams/orders/tests/with-foo']));
|
|
98
|
+
const [item] = report.cases;
|
|
99
|
+
expect(item?.ok).toBe(false);
|
|
100
|
+
expect(item?.failure?.code).toBe('UNDECLARED_FIELDS');
|
|
101
|
+
expect(item?.failure?.message).toContain('bar, foo');
|
|
102
|
+
}, 30_000);
|
|
103
|
+
|
|
104
|
+
test('extra fields on an extensible table pass; a wrong suggestion list fails; with every capability covered ok is true', async () => {
|
|
105
|
+
const all = ['streams/orders/tests/first-page', 'streams/customers/tests/extra', 'operations/create_followup/tests/ok', 'operations/create_followup/tests/rejected'];
|
|
106
|
+
const passing = await runTests(await load([...all, 'streams/customers/tests/s1']));
|
|
107
|
+
expect(passing.cases.every((item) => item.ok)).toBe(true);
|
|
108
|
+
expect(passing.uncovered).toEqual([]);
|
|
109
|
+
expect(passing.ok).toBe(true);
|
|
110
|
+
|
|
111
|
+
const failing = await runTests(await load([...all, 'streams/customers/tests/s2']));
|
|
112
|
+
expect(failing.ok).toBe(false);
|
|
113
|
+
expect(failing.cases.find((item) => item.name === 's2')?.failure?.code).toBe('EXPECTATION_FAILED');
|
|
114
|
+
}, 30_000);
|
|
115
|
+
|
|
116
|
+
test('发布门禁:操作只有成功用例时 operationCases 列出缺 REJECTED,ok 为 false;成功与拒绝用例都齐时为 []', async () => {
|
|
117
|
+
const base = ['streams/orders/tests/first-page', 'streams/customers/tests/extra'];
|
|
118
|
+
const onlyOk = await runTests(await load([...base, 'operations/create_followup/tests/ok']));
|
|
119
|
+
expect(onlyOk.uncovered).toEqual([]);
|
|
120
|
+
expect(onlyOk.cases.every((item) => item.ok)).toBe(true);
|
|
121
|
+
expect(onlyOk.operationCases).toEqual([{ operation: 'create_followup', missing: ['REJECTED'] }]);
|
|
122
|
+
expect(onlyOk.ok).toBe(false);
|
|
123
|
+
|
|
124
|
+
const both = await runTests(await load([...base, 'operations/create_followup/tests/ok', 'operations/create_followup/tests/rejected']));
|
|
125
|
+
expect(both.operationCases).toEqual([]);
|
|
126
|
+
expect(both.ok).toBe(true);
|
|
127
|
+
expect(operationCaseGaps(DESCRIPTOR, [])).toEqual([{ operation: 'create_followup', missing: ['ok', 'REJECTED'] }]);
|
|
128
|
+
}, 30_000);
|
|
129
|
+
|
|
130
|
+
test('options.cases runs only the selected cases; coverage still counts every case', async () => {
|
|
131
|
+
const connector = await load(['streams/orders/tests/first-page', 'operations/create_followup/tests/ok', 'streams/customers/tests/extra']);
|
|
132
|
+
const report = await runTests(connector, { cases: ['streams/orders'] });
|
|
133
|
+
expect(report.cases.map((item) => item.name)).toEqual(['first-page']);
|
|
134
|
+
expect(report.uncovered).toEqual([]);
|
|
135
|
+
expect(testCoverage(DESCRIPTOR, [])).toEqual({ covered: [], uncovered: ['streams/orders', 'streams/customers', 'operations/create_followup'] });
|
|
136
|
+
}, 30_000);
|
|
137
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地加载:读 `.ontos/descriptor.json`、按目录约定生成入口、导入检查、路径逃逸、符号链接与体积上限;
|
|
3
|
+
* 正常目录转译成可在 guest 里执行的打包串,能力目录下的 `tests/` 收集为用例文件而不算源码。
|
|
4
|
+
*/
|
|
5
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
6
|
+
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import ts from 'typescript';
|
|
10
|
+
import type { ConnectorDescriptor } from '../../src';
|
|
11
|
+
import { loadLocalConnector, SourceLoadError, type SourceLoadErrorCode } from '../../src/testing';
|
|
12
|
+
import { descriptor, push, table } from '../fixtures/descriptor';
|
|
13
|
+
|
|
14
|
+
const MANIFEST = { dependencies: { '@deepexi/datasense-connector': '^3.0.0' } };
|
|
15
|
+
const DESCRIPTOR = descriptor({ pushes: { default: push({ item: table({ id: 'string' }, ['id']) }) } });
|
|
16
|
+
|
|
17
|
+
const PUSH = `import { definePush } from '@deepexi/datasense-connector';
|
|
18
|
+
import { label } from './lib/label';
|
|
19
|
+
export default definePush('default', { map(ctx) { ctx.log.info(label()); } });
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
const dirs: string[] = [];
|
|
23
|
+
|
|
24
|
+
async function project(
|
|
25
|
+
files: Record<string, string>,
|
|
26
|
+
options: { manifest?: Record<string, unknown>; descriptor?: ConnectorDescriptor | Record<string, unknown> | null } = {},
|
|
27
|
+
): Promise<string> {
|
|
28
|
+
const base = await mkdtemp(path.join(os.tmpdir(), 'sdk-loader-'));
|
|
29
|
+
dirs.push(base);
|
|
30
|
+
const root = path.join(base, 'connector');
|
|
31
|
+
await mkdir(root);
|
|
32
|
+
await writeFile(path.join(root, 'package.json'), JSON.stringify(options.manifest ?? MANIFEST));
|
|
33
|
+
const desc = options.descriptor === undefined ? DESCRIPTOR : options.descriptor;
|
|
34
|
+
if (desc !== null) {
|
|
35
|
+
await mkdir(path.join(root, '.ontos'));
|
|
36
|
+
await writeFile(path.join(root, '.ontos', 'descriptor.json'), JSON.stringify(desc));
|
|
37
|
+
}
|
|
38
|
+
for (const [file, content] of Object.entries(files)) {
|
|
39
|
+
await mkdir(path.dirname(path.join(root, file)), { recursive: true });
|
|
40
|
+
await writeFile(path.join(root, file), content);
|
|
41
|
+
}
|
|
42
|
+
return root;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function expectLoadError(dir: string, code: SourceLoadErrorCode): Promise<SourceLoadError> {
|
|
46
|
+
try {
|
|
47
|
+
await loadLocalConnector(dir, { typescript: ts });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
expect(error).toBeInstanceOf(SourceLoadError);
|
|
50
|
+
expect((error as SourceLoadError).code).toBe(code);
|
|
51
|
+
return error as SourceLoadError;
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`expected SourceLoadError ${code}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
afterEach(async () => {
|
|
57
|
+
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('loadLocalConnector', () => {
|
|
61
|
+
test('a valid project becomes one CommonJS bundle whose generated entry exports { descriptor, implementations }', async () => {
|
|
62
|
+
const dir = await project({
|
|
63
|
+
'pushes/default/index.ts': PUSH,
|
|
64
|
+
'pushes/default/lib/label.ts': "import data from '../data.json';\nexport function label(): string { return `hello ${data.who}`; }\n",
|
|
65
|
+
'pushes/default/data.json': '{ "who": "world" }',
|
|
66
|
+
'pushes/default/tests/basic/case.json': '{ "config": {} }',
|
|
67
|
+
'pushes/default/tests/basic/requests.ndjson': '',
|
|
68
|
+
'node_modules/x/index.js': 'module.exports = 1;',
|
|
69
|
+
});
|
|
70
|
+
const loaded = await loadLocalConnector(dir, { typescript: ts });
|
|
71
|
+
expect(loaded.name).toBe('demo');
|
|
72
|
+
expect(loaded.version).toBe('1.0.0');
|
|
73
|
+
expect(loaded.descriptor).toEqual(DESCRIPTOR);
|
|
74
|
+
expect(loaded.sdkRange).toBe('^3.0.0');
|
|
75
|
+
// tests/ 与 .ontos/ 不算源码
|
|
76
|
+
expect(Object.keys(loaded.files).sort()).toEqual(['package.json', 'pushes/default/data.json', 'pushes/default/index.ts', 'pushes/default/lib/label.ts']);
|
|
77
|
+
expect(loaded.testFiles.map((file) => file.path)).toEqual(['pushes/default/tests/basic/case.json', 'pushes/default/tests/basic/requests.ndjson']);
|
|
78
|
+
const map = JSON.parse(loaded.sourcemap) as { version: number; sections: Array<{ map: { sources: string[] } }> };
|
|
79
|
+
expect(map.version).toBe(3);
|
|
80
|
+
expect(map.sections.map((section) => section.map.sources[0])).toEqual(['pushes/default/index.ts', 'pushes/default/lib/label.ts']);
|
|
81
|
+
|
|
82
|
+
// 打包串在 guest 外也能用一个假的 SDK require 求值:模块表按相对路径解析
|
|
83
|
+
const module = { exports: {} as Record<string, unknown> };
|
|
84
|
+
const sdk = { definePush: (name: string, impl: unknown) => ({ __ontos: 'push', name, handler: impl }) };
|
|
85
|
+
const evaluate = new Function('module', 'exports', 'require', loaded.code) as (m: unknown, e: unknown, r: (s: string) => unknown) => void;
|
|
86
|
+
evaluate(module, module.exports, (specifier) => {
|
|
87
|
+
if (specifier !== '@deepexi/datasense-connector') throw new Error(`unexpected require ${specifier}`);
|
|
88
|
+
return sdk;
|
|
89
|
+
});
|
|
90
|
+
const exported = module.exports as {
|
|
91
|
+
descriptor: ConnectorDescriptor;
|
|
92
|
+
implementations: { streams: object; suggestColumns: object; operations: object; pushes: { default: { name: string; handler: { map(ctx: unknown): void } } } };
|
|
93
|
+
};
|
|
94
|
+
expect(exported.descriptor).toEqual(DESCRIPTOR);
|
|
95
|
+
expect(exported.implementations.streams).toEqual({});
|
|
96
|
+
expect(exported.implementations.suggestColumns).toEqual({});
|
|
97
|
+
expect(exported.implementations.operations).toEqual({});
|
|
98
|
+
expect(exported.implementations.pushes.default.name).toBe('default');
|
|
99
|
+
const logs: string[] = [];
|
|
100
|
+
exported.implementations.pushes.default.handler.map({ log: { info: (message: string) => logs.push(message) } });
|
|
101
|
+
expect(logs).toEqual(['hello world']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('without .ontos/descriptor.json it is DESCRIPTOR_NOT_GENERATED and points at codegen', async () => {
|
|
105
|
+
const error = await expectLoadError(await project({ 'pushes/default/index.ts': PUSH }, { descriptor: null }), 'DESCRIPTOR_NOT_GENERATED');
|
|
106
|
+
expect(error.message).toContain('ontos admin connector codegen');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a descriptor that is not an object of the right shape, or has a bad capability name, is DESCRIPTOR_INVALID', async () => {
|
|
110
|
+
await expectLoadError(await project({}, { descriptor: { name: 'demo' } }), 'DESCRIPTOR_INVALID');
|
|
111
|
+
await expectLoadError(await project({}, { descriptor: { ...DESCRIPTOR, pushes: { '../x': push({}) } } }), 'DESCRIPTOR_INVALID');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a missing implementation file is left out of implementations (the guest then reports it)', async () => {
|
|
115
|
+
const loaded = await loadLocalConnector(await project({}), { typescript: ts });
|
|
116
|
+
const module = { exports: {} as Record<string, unknown> };
|
|
117
|
+
new Function('module', 'exports', 'require', loaded.code)(module, module.exports, () => ({}));
|
|
118
|
+
expect((module.exports as { implementations: { pushes: object } }).implementations.pushes).toEqual({});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('stream sources may import the generated .ontos/types.ts', async () => {
|
|
122
|
+
const dir = await project({
|
|
123
|
+
'pushes/default/index.ts': "import { definePush } from '../../.ontos/types';\nexport default definePush('default', { map() {} });\n",
|
|
124
|
+
});
|
|
125
|
+
await writeFile(path.join(dir, '.ontos', 'types.ts'), "import { definePush as bound } from '@deepexi/datasense-connector';\nexport const definePush = bound;\n");
|
|
126
|
+
const loaded = await loadLocalConnector(dir, { typescript: ts });
|
|
127
|
+
expect(Object.keys(loaded.files)).not.toContain('.ontos/types.ts');
|
|
128
|
+
const module = { exports: {} as Record<string, unknown> };
|
|
129
|
+
new Function('module', 'exports', 'require', loaded.code)(module, module.exports, () => ({ definePush: (name: string) => ({ name }) }));
|
|
130
|
+
expect((module.exports as { implementations: { pushes: { default: { name: string } } } }).implementations.pushes.default.name).toBe('default');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a JSON module keeps JSON semantics: "__proto__" is an own key, not the prototype', async () => {
|
|
134
|
+
const dir = await project({
|
|
135
|
+
'pushes/default/index.ts': "import data from './data.json';\nexport default data;\n",
|
|
136
|
+
'pushes/default/data.json': '{\n "__proto__": { "polluted": true },\n "plain": 1\n}\n',
|
|
137
|
+
});
|
|
138
|
+
const loaded = await loadLocalConnector(dir, { typescript: ts });
|
|
139
|
+
const module = { exports: {} as Record<string, unknown> };
|
|
140
|
+
const evaluate = new Function('module', 'exports', 'require', loaded.code) as (m: unknown, e: unknown, r: (s: string) => unknown) => void;
|
|
141
|
+
evaluate(module, module.exports, () => ({}));
|
|
142
|
+
const data = (module.exports as { implementations: { pushes: { default: Record<string, unknown> } } }).implementations.pushes.default;
|
|
143
|
+
expect(Object.hasOwn(data, '__proto__')).toBe(true);
|
|
144
|
+
expect(Object.getPrototypeOf(data)).toBe(Object.prototype);
|
|
145
|
+
expect((data as { polluted?: unknown }).polluted).toBeUndefined();
|
|
146
|
+
expect(data.plain).toBe(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("import fs from 'node:fs' is IMPORT_FORBIDDEN even when unused", async () => {
|
|
150
|
+
const error = await expectLoadError(await project({ 'pushes/default/index.ts': `import fs from 'node:fs';\n${PUSH}`, 'pushes/default/lib/label.ts': 'export const label = () => "x";' }), 'IMPORT_FORBIDDEN');
|
|
151
|
+
expect(error.file).toBe('pushes/default/index.ts');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("import x from 'lodash' is IMPORT_FORBIDDEN", async () => {
|
|
155
|
+
await expectLoadError(await project({ 'pushes/default/index.ts': `import x from 'lodash';\nexport default x;\n` }), 'IMPORT_FORBIDDEN');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("import y from '../../../outside' is PATH_ESCAPE", async () => {
|
|
159
|
+
await expectLoadError(await project({ 'pushes/default/index.ts': `import y from '../../../outside';\nexport default y;\n` }), 'PATH_ESCAPE');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('import attributes are IMPORT_FORBIDDEN', async () => {
|
|
163
|
+
await expectLoadError(await project({ 'pushes/default/index.ts': `import z from './a.json' with { type: 'json' };\nexport default z;\n`, 'pushes/default/a.json': '{}' }), 'IMPORT_FORBIDDEN');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('dynamic import with options is IMPORT_FORBIDDEN', async () => {
|
|
167
|
+
await expectLoadError(
|
|
168
|
+
await project({ 'pushes/default/index.ts': `export async function f() { return await import('./b', { with: { type: 'macro' } }); }\n`, 'pushes/default/b.ts': 'export const b = 1;' }),
|
|
169
|
+
'IMPORT_FORBIDDEN',
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('a computed require is IMPORT_FORBIDDEN', async () => {
|
|
174
|
+
await expectLoadError(await project({ 'pushes/default/index.ts': `const name = 'fs';\nexport const x = require(name);\n` }), 'IMPORT_FORBIDDEN');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('the SDK testing subpath is IMPORT_FORBIDDEN', async () => {
|
|
178
|
+
await expectLoadError(await project({ 'pushes/default/index.ts': `import { runStream } from '@deepexi/datasense-connector/testing';\nexport default runStream;\n` }), 'IMPORT_FORBIDDEN');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('a symbolic link pointing outside the source root is SYMLINK', async () => {
|
|
182
|
+
const dir = await project({ 'pushes/default/index.ts': PUSH, 'pushes/default/lib/label.ts': 'export const label = () => "x";' });
|
|
183
|
+
await writeFile(path.join(dir, '..', 'outside.ts'), 'export const secret = 1;');
|
|
184
|
+
await symlink(path.join(dir, '..', 'outside.ts'), path.join(dir, 'pushes', 'default', 'outside.ts'));
|
|
185
|
+
const error = await expectLoadError(dir, 'SYMLINK');
|
|
186
|
+
expect(error.file).toBe('pushes/default/outside.ts');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('node_modules / dist that are themselves symbolic links are skipped, not SYMLINK', async () => {
|
|
190
|
+
const dir = await project({ 'pushes/default/index.ts': PUSH, 'pushes/default/lib/label.ts': 'export const label = () => "x";' });
|
|
191
|
+
const elsewhere = await mkdtemp(path.join(os.tmpdir(), 'sdk-linked-modules-'));
|
|
192
|
+
await symlink(elsewhere, path.join(dir, 'node_modules'), 'dir');
|
|
193
|
+
await symlink(elsewhere, path.join(dir, 'dist'), 'dir');
|
|
194
|
+
const loaded = await loadLocalConnector(dir, { typescript: ts });
|
|
195
|
+
expect(Object.keys(loaded.files).some((file) => file.startsWith('node_modules') || file.startsWith('dist'))).toBe(false);
|
|
196
|
+
await rm(elsewhere, { recursive: true, force: true });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('more than 2 MB of source is TOO_LARGE', async () => {
|
|
200
|
+
const big = `export const blob = ${JSON.stringify('x'.repeat(1024 * 1024))};\n`;
|
|
201
|
+
await expectLoadError(await project({ 'pushes/default/index.ts': PUSH, 'pushes/default/a.ts': big, 'pushes/default/b.ts': big, 'pushes/default/c.ts': big }), 'TOO_LARGE');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('a syntax error is SYNTAX_ERROR with the file', async () => {
|
|
205
|
+
const error = await expectLoadError(await project({ 'pushes/default/index.ts': 'export const = 1;\n' }), 'SYNTAX_ERROR');
|
|
206
|
+
expect(error.file).toBe('pushes/default/index.ts');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('dependencies other than the SDK are MANIFEST_INVALID', async () => {
|
|
210
|
+
await expectLoadError(
|
|
211
|
+
await project({ 'pushes/default/index.ts': PUSH }, { manifest: { ...MANIFEST, dependencies: { '@deepexi/datasense-connector': '^3.0.0', lodash: '^4.0.0' } } }),
|
|
212
|
+
'MANIFEST_INVALID',
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('typescript must be the compiler API', async () => {
|
|
217
|
+
const dir = await project({ 'pushes/default/index.ts': PUSH });
|
|
218
|
+
await expect(loadLocalConnector(dir, { typescript: {} })).rejects.toThrow(TypeError);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 按描述校验值:连接配置(`validateConfig`)、连接器操作的入参与输出(`validateOperationValue`)、建议列(`validateSuggestedColumns`)。
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
import {
|
|
6
|
+
PRESENTATION_LIMITS, SUGGESTED_COLUMNS_MAX, validateConfig, validateOperationValue, validateSuggestedColumns,
|
|
7
|
+
} from '../src';
|
|
8
|
+
import { configField, valueField } from './fixtures/descriptor';
|
|
9
|
+
|
|
10
|
+
const paths = (issues: Array<{ path: string }>) => issues.map((issue) => issue.path);
|
|
11
|
+
|
|
12
|
+
describe('validateConfig', () => {
|
|
13
|
+
test('{ accountSet: optional string, port: integer } with { port: "x" } reports only port', () => {
|
|
14
|
+
const fields = { accountSet: configField('string', { optional: true }), port: configField('integer') };
|
|
15
|
+
expect(paths(validateConfig(fields, { port: 'x' }))).toEqual(['port']);
|
|
16
|
+
expect(validateConfig(fields, { port: 8080 })).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('a required field that is missing or null is reported; a field with a default may be missing', () => {
|
|
20
|
+
const fields = { host: configField('string'), pageSize: configField('integer', { default: 100 }) };
|
|
21
|
+
expect(paths(validateConfig(fields, {}))).toEqual(['host']);
|
|
22
|
+
expect(paths(validateConfig(fields, { host: null }))).toEqual(['host']);
|
|
23
|
+
expect(validateConfig(fields, { host: 'a' })).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('url accepts only http and https', () => {
|
|
27
|
+
const fields = { baseUrl: configField('url') };
|
|
28
|
+
expect(validateConfig(fields, { baseUrl: 'https://api.example.com/v1' })).toEqual([]);
|
|
29
|
+
expect(validateConfig(fields, { baseUrl: 'http://localhost:8080' })).toEqual([]);
|
|
30
|
+
for (const bad of ['ftp://example.com', 'example.com', 'https://', 'javascript:alert(1)', 42]) {
|
|
31
|
+
expect(paths(validateConfig(fields, { baseUrl: bad }))).toEqual(['baseUrl']);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('enum accepts only the listed values', () => {
|
|
36
|
+
const fields = { region: configField('enum', { values: ['cn', 'us'] }) };
|
|
37
|
+
expect(validateConfig(fields, { region: 'cn' })).toEqual([]);
|
|
38
|
+
expect(paths(validateConfig(fields, { region: 'eu' }))).toEqual(['region']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('secret fields are not checked; undeclared keys are reported', () => {
|
|
42
|
+
const fields = { token: configField('secret'), host: configField('string') };
|
|
43
|
+
expect(validateConfig(fields, { host: 'a' })).toEqual([]);
|
|
44
|
+
expect(paths(validateConfig(fields, { host: 'a', extra: 1 }))).toEqual(['extra']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('boolean and decimal values', () => {
|
|
48
|
+
const fields = { on: configField('boolean'), rate: configField('decimal') };
|
|
49
|
+
expect(validateConfig(fields, { on: true, rate: '1.25' })).toEqual([]);
|
|
50
|
+
expect(paths(validateConfig(fields, { on: 'yes', rate: 'abc' }))).toEqual(['on', 'rate']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('PRESENTATION_LIMITS keeps its values', () => {
|
|
54
|
+
expect(PRESENTATION_LIMITS).toMatchObject({ title: 40, description: 120, fieldTitle: 40, fieldDescription: 200, tableTitle: 40, tableDescription: 200 });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('validateOperationValue', () => {
|
|
59
|
+
const fields = {
|
|
60
|
+
sku: valueField('string'),
|
|
61
|
+
qty: valueField('integer'),
|
|
62
|
+
note: valueField('string', { optional: true }),
|
|
63
|
+
lines: valueField('array', { item: valueField('object', { fields: { n: valueField('integer') } }) }),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
test('missing, wrong-typed, nested and undeclared fields are reported with their paths', () => {
|
|
67
|
+
expect(validateOperationValue(fields, { sku: 'A', qty: 1, lines: [{ n: 1 }] }, 'input')).toEqual([]);
|
|
68
|
+
expect(paths(validateOperationValue(fields, { qty: 'x', lines: [{ n: 'y' }], extra: true }, 'input')).sort())
|
|
69
|
+
.toEqual(['input.extra', 'input.lines[0].n', 'input.qty', 'input.sku']);
|
|
70
|
+
expect(paths(validateOperationValue(fields, 'nope', 'output'))).toEqual(['output']);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('validateSuggestedColumns', () => {
|
|
75
|
+
test('valid columns come back as plain SuggestedColumn objects', () => {
|
|
76
|
+
const result = validateSuggestedColumns([
|
|
77
|
+
{ name: 'vip_level', type: 'string', title: 'VIP level' },
|
|
78
|
+
{ name: 'credit', type: 'decimal', precision: 12, scale: 2, title: 'Credit', description: 'Credit limit' },
|
|
79
|
+
]);
|
|
80
|
+
expect(result).toEqual({
|
|
81
|
+
ok: true,
|
|
82
|
+
columns: [
|
|
83
|
+
{ name: 'vip_level', type: 'string', title: 'VIP level' },
|
|
84
|
+
{ name: 'credit', type: 'decimal', precision: 12, scale: 2, title: 'Credit', description: 'Credit limit' },
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('bad names, reserved prefixes, duplicates, unknown types, missing titles and decimals without precision are rejected', () => {
|
|
90
|
+
const result = validateSuggestedColumns([
|
|
91
|
+
{ name: '1bad', type: 'string', title: 'x' },
|
|
92
|
+
{ name: '_ontos_tenant', type: 'string', title: 'x' },
|
|
93
|
+
{ name: 'dup', type: 'string', title: 'x' },
|
|
94
|
+
{ name: 'dup', type: 'string', title: 'x' },
|
|
95
|
+
{ name: 'kind', type: 'text', title: 'x' },
|
|
96
|
+
{ name: 'untitled', type: 'string' },
|
|
97
|
+
{ name: 'amount', type: 'decimal', title: 'x' },
|
|
98
|
+
]);
|
|
99
|
+
expect(result.ok).toBe(false);
|
|
100
|
+
if (!result.ok) expect(result.issues.length).toBeGreaterThanOrEqual(6);
|
|
101
|
+
expect(validateSuggestedColumns({}).ok).toBe(false);
|
|
102
|
+
expect(validateSuggestedColumns(Array.from({ length: SUGGESTED_COLUMNS_MAX + 1 }, (_, i) => ({ name: `c${i}`, type: 'string', title: 'c' }))).ok).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
// 发布产物(dist/)的 ESM 编译:tsc 只出 .js 与 .d.ts,扩展名与 CJS 版本由 scripts/build.ts 补齐。
|
|
3
|
+
"extends": "../../tsconfig.base.json",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"lib": ["ESNext"],
|
|
6
|
+
"target": "ES2022",
|
|
7
|
+
"module": "ESNext",
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"moduleDetection": "force",
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"noUncheckedIndexedAccess": true,
|
|
14
|
+
"types": ["bun"],
|
|
15
|
+
"rootDir": "src",
|
|
16
|
+
"outDir": "dist",
|
|
17
|
+
"declaration": true,
|
|
18
|
+
"sourceMap": false,
|
|
19
|
+
"noEmit": false,
|
|
20
|
+
"removeComments": false
|
|
21
|
+
},
|
|
22
|
+
"include": ["src"]
|
|
23
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"moduleDetection": "force",
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"strict": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"noUncheckedIndexedAccess": true,
|
|
15
|
+
"types": ["bun"],
|
|
16
|
+
"paths": { "@deepexi/datasense-connector": ["./src/index.ts"] }
|
|
17
|
+
},
|
|
18
|
+
"include": ["src", "test"]
|
|
19
|
+
}
|