datagrok-tools 6.1.8 → 6.1.10
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/CHANGELOG.md +10 -0
- package/CLAUDE.md +68 -0
- package/bin/__tests__/build.test.js +116 -0
- package/bin/__tests__/build.test.ts +101 -0
- package/bin/__tests__/node-dapi.connections.test.js +120 -0
- package/bin/__tests__/node-dapi.connections.test.ts +84 -0
- package/bin/__tests__/node-dapi.groups.test.js +467 -0
- package/bin/__tests__/node-dapi.groups.test.ts +298 -0
- package/bin/__tests__/node-dapi.integration.test.js +406 -0
- package/bin/__tests__/node-dapi.integration.test.ts +447 -0
- package/bin/__tests__/node-dapi.shares.test.js +107 -0
- package/bin/__tests__/node-dapi.shares.test.ts +70 -0
- package/bin/__tests__/node-dapi.users.test.js +86 -0
- package/bin/__tests__/node-dapi.users.test.ts +58 -0
- package/bin/__tests__/server-output.test.js +171 -0
- package/bin/__tests__/server-output.test.ts +133 -0
- package/bin/__tests__/server.test.js +277 -0
- package/bin/__tests__/server.test.ts +197 -0
- package/bin/commands/build.js +1 -1
- package/bin/commands/create.js +8 -5
- package/bin/commands/help.js +61 -1
- package/bin/commands/publish.js +12 -1
- package/bin/commands/server.js +520 -0
- package/bin/grok.js +3 -1
- package/bin/utils/node-dapi.js +459 -0
- package/bin/utils/server-client.js +15 -0
- package/bin/utils/server-output.js +127 -0
- package/package-template/package.json +1 -1
- package/package.json +8 -3
- package/vitest.config.ts +25 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _vitest = require("vitest");
|
|
4
|
+
var _nodeDapi = require("../utils/node-dapi");
|
|
5
|
+
function makeMock(responder) {
|
|
6
|
+
const calls = [];
|
|
7
|
+
const client = {
|
|
8
|
+
async request(method, path, body) {
|
|
9
|
+
calls.push({
|
|
10
|
+
method,
|
|
11
|
+
path,
|
|
12
|
+
body
|
|
13
|
+
});
|
|
14
|
+
return responder(method, path, body);
|
|
15
|
+
},
|
|
16
|
+
get(path) {
|
|
17
|
+
return this.request('GET', path);
|
|
18
|
+
},
|
|
19
|
+
post(path, body) {
|
|
20
|
+
return this.request('POST', path, body);
|
|
21
|
+
},
|
|
22
|
+
del(path) {
|
|
23
|
+
return this.request('DELETE', path);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
client,
|
|
28
|
+
calls
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
32
|
+
(0, _vitest.describe)('ensureBodyId', () => {
|
|
33
|
+
(0, _vitest.it)('assigns a UUID when id is missing', () => {
|
|
34
|
+
const body = {
|
|
35
|
+
name: 'alice'
|
|
36
|
+
};
|
|
37
|
+
(0, _nodeDapi.ensureBodyId)(body);
|
|
38
|
+
(0, _vitest.expect)(body.id).toMatch(UUID_RE);
|
|
39
|
+
});
|
|
40
|
+
(0, _vitest.it)('preserves an existing id', () => {
|
|
41
|
+
const body = {
|
|
42
|
+
id: 'preset-id',
|
|
43
|
+
name: 'alice'
|
|
44
|
+
};
|
|
45
|
+
(0, _nodeDapi.ensureBodyId)(body);
|
|
46
|
+
(0, _vitest.expect)(body.id).toBe('preset-id');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
(0, _vitest.describe)('NodeUsersDataSource.save', () => {
|
|
50
|
+
(0, _vitest.it)('POSTs to /public/v1/users and generates an id when missing', async () => {
|
|
51
|
+
const {
|
|
52
|
+
client,
|
|
53
|
+
calls
|
|
54
|
+
} = makeMock((method, path, body) => {
|
|
55
|
+
if (method === 'POST' && path === '/public/v1/users') return {
|
|
56
|
+
...body,
|
|
57
|
+
name: 'alice'
|
|
58
|
+
};
|
|
59
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
60
|
+
});
|
|
61
|
+
const ds = new _nodeDapi.NodeUsersDataSource(client);
|
|
62
|
+
const result = await ds.save({
|
|
63
|
+
firstName: 'Alice',
|
|
64
|
+
lastName: 'A',
|
|
65
|
+
email: 'a@x.com'
|
|
66
|
+
});
|
|
67
|
+
(0, _vitest.expect)(calls[0].path).toBe('/public/v1/users');
|
|
68
|
+
(0, _vitest.expect)(calls[0].body.id).toMatch(UUID_RE);
|
|
69
|
+
(0, _vitest.expect)(result.name).toBe('alice');
|
|
70
|
+
});
|
|
71
|
+
(0, _vitest.it)('preserves an existing id in the body', async () => {
|
|
72
|
+
const {
|
|
73
|
+
client,
|
|
74
|
+
calls
|
|
75
|
+
} = makeMock((method, path, body) => {
|
|
76
|
+
if (method === 'POST' && path === '/public/v1/users') return body;
|
|
77
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
78
|
+
});
|
|
79
|
+
const ds = new _nodeDapi.NodeUsersDataSource(client);
|
|
80
|
+
await ds.save({
|
|
81
|
+
id: 'existing-uuid',
|
|
82
|
+
firstName: 'Alice'
|
|
83
|
+
});
|
|
84
|
+
(0, _vitest.expect)(calls[0].body.id).toBe('existing-uuid');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {describe, it, expect} from 'vitest';
|
|
2
|
+
import {NodeUsersDataSource, ensureBodyId} from '../utils/node-dapi';
|
|
3
|
+
|
|
4
|
+
interface Call {method: string; path: string; body?: any}
|
|
5
|
+
|
|
6
|
+
function makeMock(responder: (method: string, path: string, body?: any) => any) {
|
|
7
|
+
const calls: Call[] = [];
|
|
8
|
+
const client: any = {
|
|
9
|
+
async request(method: string, path: string, body?: any) {
|
|
10
|
+
calls.push({method, path, body});
|
|
11
|
+
return responder(method, path, body);
|
|
12
|
+
},
|
|
13
|
+
get(path: string) { return this.request('GET', path); },
|
|
14
|
+
post(path: string, body?: any) { return this.request('POST', path, body); },
|
|
15
|
+
del(path: string) { return this.request('DELETE', path); },
|
|
16
|
+
};
|
|
17
|
+
return {client, calls};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21
|
+
|
|
22
|
+
describe('ensureBodyId', () => {
|
|
23
|
+
it('assigns a UUID when id is missing', () => {
|
|
24
|
+
const body = {name: 'alice'};
|
|
25
|
+
ensureBodyId(body);
|
|
26
|
+
expect((body as any).id).toMatch(UUID_RE);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('preserves an existing id', () => {
|
|
30
|
+
const body = {id: 'preset-id', name: 'alice'};
|
|
31
|
+
ensureBodyId(body);
|
|
32
|
+
expect(body.id).toBe('preset-id');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('NodeUsersDataSource.save', () => {
|
|
37
|
+
it('POSTs to /public/v1/users and generates an id when missing', async () => {
|
|
38
|
+
const {client, calls} = makeMock((method, path, body) => {
|
|
39
|
+
if (method === 'POST' && path === '/public/v1/users') return {...body, name: 'alice'};
|
|
40
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
41
|
+
});
|
|
42
|
+
const ds = new NodeUsersDataSource(client);
|
|
43
|
+
const result = await ds.save({firstName: 'Alice', lastName: 'A', email: 'a@x.com'});
|
|
44
|
+
expect(calls[0].path).toBe('/public/v1/users');
|
|
45
|
+
expect(calls[0].body.id).toMatch(UUID_RE);
|
|
46
|
+
expect(result.name).toBe('alice');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('preserves an existing id in the body', async () => {
|
|
50
|
+
const {client, calls} = makeMock((method, path, body) => {
|
|
51
|
+
if (method === 'POST' && path === '/public/v1/users') return body;
|
|
52
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
53
|
+
});
|
|
54
|
+
const ds = new NodeUsersDataSource(client);
|
|
55
|
+
await ds.save({id: 'existing-uuid', firstName: 'Alice'});
|
|
56
|
+
expect(calls[0].body.id).toBe('existing-uuid');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _vitest = require("vitest");
|
|
4
|
+
var _serverOutput = require("../utils/server-output");
|
|
5
|
+
(0, _vitest.describe)('cellStr', () => {
|
|
6
|
+
(0, _vitest.it)('returns empty string for null', () => {
|
|
7
|
+
(0, _vitest.expect)((0, _serverOutput.cellStr)(null)).toBe('');
|
|
8
|
+
});
|
|
9
|
+
(0, _vitest.it)('returns empty string for undefined', () => {
|
|
10
|
+
(0, _vitest.expect)((0, _serverOutput.cellStr)(undefined)).toBe('');
|
|
11
|
+
});
|
|
12
|
+
(0, _vitest.it)('returns the name property for objects with a name', () => {
|
|
13
|
+
(0, _vitest.expect)((0, _serverOutput.cellStr)({
|
|
14
|
+
name: 'Alice',
|
|
15
|
+
id: '1'
|
|
16
|
+
})).toBe('Alice');
|
|
17
|
+
});
|
|
18
|
+
(0, _vitest.it)('falls back to id when name is absent', () => {
|
|
19
|
+
(0, _vitest.expect)((0, _serverOutput.cellStr)({
|
|
20
|
+
id: 'abc-123'
|
|
21
|
+
})).toBe('abc-123');
|
|
22
|
+
});
|
|
23
|
+
(0, _vitest.it)('returns truncated JSON for objects with neither name nor id', () => {
|
|
24
|
+
const result = (0, _serverOutput.cellStr)({
|
|
25
|
+
foo: 'bar',
|
|
26
|
+
baz: 42
|
|
27
|
+
});
|
|
28
|
+
(0, _vitest.expect)(result).toBe('{"foo":"bar","baz":42}');
|
|
29
|
+
(0, _vitest.expect)(result.length).toBeLessThanOrEqual(40);
|
|
30
|
+
});
|
|
31
|
+
(0, _vitest.it)('truncates long JSON to 40 characters', () => {
|
|
32
|
+
const obj = {
|
|
33
|
+
key: 'a'.repeat(50)
|
|
34
|
+
};
|
|
35
|
+
(0, _vitest.expect)((0, _serverOutput.cellStr)(obj).length).toBe(40);
|
|
36
|
+
});
|
|
37
|
+
(0, _vitest.it)('converts a number to string', () => {
|
|
38
|
+
(0, _vitest.expect)((0, _serverOutput.cellStr)(123)).toBe('123');
|
|
39
|
+
});
|
|
40
|
+
(0, _vitest.it)('converts a boolean to string', () => {
|
|
41
|
+
(0, _vitest.expect)((0, _serverOutput.cellStr)(true)).toBe('true');
|
|
42
|
+
(0, _vitest.expect)((0, _serverOutput.cellStr)(false)).toBe('false');
|
|
43
|
+
});
|
|
44
|
+
(0, _vitest.it)('returns a plain string as-is', () => {
|
|
45
|
+
(0, _vitest.expect)((0, _serverOutput.cellStr)('hello')).toBe('hello');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
(0, _vitest.describe)('csvCell', () => {
|
|
49
|
+
(0, _vitest.it)('returns the string unchanged when no special chars', () => {
|
|
50
|
+
(0, _vitest.expect)((0, _serverOutput.csvCell)('hello')).toBe('hello');
|
|
51
|
+
});
|
|
52
|
+
(0, _vitest.it)('wraps in quotes when the value contains a comma', () => {
|
|
53
|
+
(0, _vitest.expect)((0, _serverOutput.csvCell)('a,b')).toBe('"a,b"');
|
|
54
|
+
});
|
|
55
|
+
(0, _vitest.it)('wraps and escapes double quotes', () => {
|
|
56
|
+
(0, _vitest.expect)((0, _serverOutput.csvCell)('say "hi"')).toBe('"say ""hi"""');
|
|
57
|
+
});
|
|
58
|
+
(0, _vitest.it)('wraps in quotes when the value contains a newline', () => {
|
|
59
|
+
(0, _vitest.expect)((0, _serverOutput.csvCell)('line1\nline2')).toBe('"line1\nline2"');
|
|
60
|
+
});
|
|
61
|
+
(0, _vitest.it)('handles an empty string', () => {
|
|
62
|
+
(0, _vitest.expect)((0, _serverOutput.csvCell)('')).toBe('');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
(0, _vitest.describe)('getKeys', () => {
|
|
66
|
+
(0, _vitest.it)('returns an empty array for an empty input', () => {
|
|
67
|
+
(0, _vitest.expect)((0, _serverOutput.getKeys)([])).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
(0, _vitest.it)('returns keys from a single object', () => {
|
|
70
|
+
(0, _vitest.expect)((0, _serverOutput.getKeys)([{
|
|
71
|
+
a: 1,
|
|
72
|
+
b: 2
|
|
73
|
+
}])).toEqual(['a', 'b']);
|
|
74
|
+
});
|
|
75
|
+
(0, _vitest.it)('unions keys across multiple objects', () => {
|
|
76
|
+
(0, _vitest.expect)((0, _serverOutput.getKeys)([{
|
|
77
|
+
a: 1
|
|
78
|
+
}, {
|
|
79
|
+
b: 2
|
|
80
|
+
}, {
|
|
81
|
+
a: 3,
|
|
82
|
+
c: 4
|
|
83
|
+
}])).toEqual(['a', 'b', 'c']);
|
|
84
|
+
});
|
|
85
|
+
(0, _vitest.it)('deduplicates keys', () => {
|
|
86
|
+
(0, _vitest.expect)((0, _serverOutput.getKeys)([{
|
|
87
|
+
a: 1,
|
|
88
|
+
b: 2
|
|
89
|
+
}, {
|
|
90
|
+
a: 3,
|
|
91
|
+
b: 4
|
|
92
|
+
}])).toEqual(['a', 'b']);
|
|
93
|
+
});
|
|
94
|
+
(0, _vitest.it)('preserves insertion order (first appearance wins)', () => {
|
|
95
|
+
(0, _vitest.expect)((0, _serverOutput.getKeys)([{
|
|
96
|
+
b: 1,
|
|
97
|
+
a: 2
|
|
98
|
+
}, {
|
|
99
|
+
a: 3,
|
|
100
|
+
c: 4
|
|
101
|
+
}])).toEqual(['b', 'a', 'c']);
|
|
102
|
+
});
|
|
103
|
+
(0, _vitest.it)('caps the result at 12 keys', () => {
|
|
104
|
+
const row = Object.fromEntries(Array.from({
|
|
105
|
+
length: 15
|
|
106
|
+
}, (_, i) => [`k${i}`, i]));
|
|
107
|
+
const keys = (0, _serverOutput.getKeys)([row]);
|
|
108
|
+
(0, _vitest.expect)(keys).toHaveLength(12);
|
|
109
|
+
(0, _vitest.expect)(keys).toEqual(Array.from({
|
|
110
|
+
length: 12
|
|
111
|
+
}, (_, i) => `k${i}`));
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
(0, _vitest.describe)('printBatchOutput', () => {
|
|
115
|
+
let logLines;
|
|
116
|
+
let errLines;
|
|
117
|
+
(0, _vitest.beforeEach)(() => {
|
|
118
|
+
logLines = [];
|
|
119
|
+
errLines = [];
|
|
120
|
+
_vitest.vi.spyOn(console, 'log').mockImplementation((...args) => logLines.push(args.join(' ')));
|
|
121
|
+
_vitest.vi.spyOn(process.stderr, 'write').mockImplementation(chunk => {
|
|
122
|
+
errLines.push(String(chunk));
|
|
123
|
+
return true;
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
(0, _vitest.afterEach)(() => {
|
|
127
|
+
_vitest.vi.restoreAllMocks();
|
|
128
|
+
});
|
|
129
|
+
(0, _vitest.it)('json: output is valid JSON equal to the full response', () => {
|
|
130
|
+
const resp = {
|
|
131
|
+
summary: {
|
|
132
|
+
total: 1,
|
|
133
|
+
succeeded: 1,
|
|
134
|
+
failed: 0,
|
|
135
|
+
partial: 0,
|
|
136
|
+
skipped: 0
|
|
137
|
+
},
|
|
138
|
+
results: [{
|
|
139
|
+
id: 'op0',
|
|
140
|
+
action: 'users.delete',
|
|
141
|
+
status: 'success'
|
|
142
|
+
}]
|
|
143
|
+
};
|
|
144
|
+
(0, _serverOutput.printBatchOutput)(resp, 'json');
|
|
145
|
+
(0, _vitest.expect)(JSON.parse(logLines[0])).toEqual(resp);
|
|
146
|
+
});
|
|
147
|
+
(0, _vitest.it)('table: failed operations are written to stderr as structured JSON', () => {
|
|
148
|
+
const resp = {
|
|
149
|
+
summary: {
|
|
150
|
+
total: 1,
|
|
151
|
+
succeeded: 0,
|
|
152
|
+
failed: 1,
|
|
153
|
+
partial: 0,
|
|
154
|
+
skipped: 0
|
|
155
|
+
},
|
|
156
|
+
results: [{
|
|
157
|
+
id: 'op0',
|
|
158
|
+
action: 'users.delete',
|
|
159
|
+
status: 'error',
|
|
160
|
+
error: {
|
|
161
|
+
error: 'Not found'
|
|
162
|
+
}
|
|
163
|
+
}]
|
|
164
|
+
};
|
|
165
|
+
(0, _serverOutput.printBatchOutput)(resp, 'table');
|
|
166
|
+
(0, _vitest.expect)(errLines.length).toBeGreaterThan(0);
|
|
167
|
+
const parsed = JSON.parse(errLines[0]);
|
|
168
|
+
(0, _vitest.expect)(parsed[0].id).toBe('op0');
|
|
169
|
+
(0, _vitest.expect)(parsed[0].error.error).toBe('Not found');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
2
|
+
import {cellStr, csvCell, getKeys, printBatchOutput} from '../utils/server-output';
|
|
3
|
+
import type {BatchResponse} from '../utils/node-dapi';
|
|
4
|
+
|
|
5
|
+
describe('cellStr', () => {
|
|
6
|
+
it('returns empty string for null', () => {
|
|
7
|
+
expect(cellStr(null)).toBe('');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('returns empty string for undefined', () => {
|
|
11
|
+
expect(cellStr(undefined)).toBe('');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('returns the name property for objects with a name', () => {
|
|
15
|
+
expect(cellStr({name: 'Alice', id: '1'})).toBe('Alice');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('falls back to id when name is absent', () => {
|
|
19
|
+
expect(cellStr({id: 'abc-123'})).toBe('abc-123');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns truncated JSON for objects with neither name nor id', () => {
|
|
23
|
+
const result = cellStr({foo: 'bar', baz: 42});
|
|
24
|
+
expect(result).toBe('{"foo":"bar","baz":42}');
|
|
25
|
+
expect(result.length).toBeLessThanOrEqual(40);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('truncates long JSON to 40 characters', () => {
|
|
29
|
+
const obj = {key: 'a'.repeat(50)};
|
|
30
|
+
expect(cellStr(obj).length).toBe(40);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('converts a number to string', () => {
|
|
34
|
+
expect(cellStr(123)).toBe('123');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('converts a boolean to string', () => {
|
|
38
|
+
expect(cellStr(true)).toBe('true');
|
|
39
|
+
expect(cellStr(false)).toBe('false');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns a plain string as-is', () => {
|
|
43
|
+
expect(cellStr('hello')).toBe('hello');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('csvCell', () => {
|
|
48
|
+
it('returns the string unchanged when no special chars', () => {
|
|
49
|
+
expect(csvCell('hello')).toBe('hello');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('wraps in quotes when the value contains a comma', () => {
|
|
53
|
+
expect(csvCell('a,b')).toBe('"a,b"');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('wraps and escapes double quotes', () => {
|
|
57
|
+
expect(csvCell('say "hi"')).toBe('"say ""hi"""');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('wraps in quotes when the value contains a newline', () => {
|
|
61
|
+
expect(csvCell('line1\nline2')).toBe('"line1\nline2"');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('handles an empty string', () => {
|
|
65
|
+
expect(csvCell('')).toBe('');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('getKeys', () => {
|
|
70
|
+
it('returns an empty array for an empty input', () => {
|
|
71
|
+
expect(getKeys([])).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns keys from a single object', () => {
|
|
75
|
+
expect(getKeys([{a: 1, b: 2}])).toEqual(['a', 'b']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('unions keys across multiple objects', () => {
|
|
79
|
+
expect(getKeys([{a: 1}, {b: 2}, {a: 3, c: 4}])).toEqual(['a', 'b', 'c']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('deduplicates keys', () => {
|
|
83
|
+
expect(getKeys([{a: 1, b: 2}, {a: 3, b: 4}])).toEqual(['a', 'b']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('preserves insertion order (first appearance wins)', () => {
|
|
87
|
+
expect(getKeys([{b: 1, a: 2}, {a: 3, c: 4}])).toEqual(['b', 'a', 'c']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('caps the result at 12 keys', () => {
|
|
91
|
+
const row = Object.fromEntries(Array.from({length: 15}, (_, i) => [`k${i}`, i]));
|
|
92
|
+
const keys = getKeys([row]);
|
|
93
|
+
expect(keys).toHaveLength(12);
|
|
94
|
+
expect(keys).toEqual(Array.from({length: 12}, (_, i) => `k${i}`));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('printBatchOutput', () => {
|
|
99
|
+
let logLines: string[];
|
|
100
|
+
let errLines: string[];
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
logLines = [];
|
|
104
|
+
errLines = [];
|
|
105
|
+
vi.spyOn(console, 'log').mockImplementation((...args) => logLines.push(args.join(' ')));
|
|
106
|
+
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { errLines.push(String(chunk)); return true; });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
vi.restoreAllMocks();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('json: output is valid JSON equal to the full response', () => {
|
|
114
|
+
const resp: BatchResponse = {
|
|
115
|
+
summary: {total: 1, succeeded: 1, failed: 0, partial: 0, skipped: 0},
|
|
116
|
+
results: [{id: 'op0', action: 'users.delete', status: 'success'}],
|
|
117
|
+
};
|
|
118
|
+
printBatchOutput(resp, 'json');
|
|
119
|
+
expect(JSON.parse(logLines[0])).toEqual(resp);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('table: failed operations are written to stderr as structured JSON', () => {
|
|
123
|
+
const resp: BatchResponse = {
|
|
124
|
+
summary: {total: 1, succeeded: 0, failed: 1, partial: 0, skipped: 0},
|
|
125
|
+
results: [{id: 'op0', action: 'users.delete', status: 'error', error: {error: 'Not found'}}],
|
|
126
|
+
};
|
|
127
|
+
printBatchOutput(resp, 'table');
|
|
128
|
+
expect(errLines.length).toBeGreaterThan(0);
|
|
129
|
+
const parsed = JSON.parse(errLines[0]);
|
|
130
|
+
expect(parsed[0].id).toBe('op0');
|
|
131
|
+
expect(parsed[0].error.error).toBe('Not found');
|
|
132
|
+
});
|
|
133
|
+
});
|