datagrok-tools 6.5.1 → 6.5.2
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 +4 -0
- package/bin/__tests__/build.test.js +116 -0
- package/bin/__tests__/jira-wiki-converter.test.js +101 -0
- package/bin/__tests__/node-dapi.connections.test.js +120 -0
- package/bin/__tests__/node-dapi.groups.test.js +467 -0
- package/bin/commands/publish.js +15 -7
- package/bin/commands/stress-tests.js +3 -0
- package/package-template/src/package-test.ts +10 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Datagrok-tools changelog
|
|
2
2
|
|
|
3
|
+
## 6.5.2 (2026-07-27)
|
|
4
|
+
|
|
5
|
+
* `grok publish` — a failed `docker push` is no longer reported as a successful one. `image.json` claimed the tag regardless, so the server recorded a container image that was never published and the spawner failed validation forever ("Image ... not found in any registry") with no recovery short of another publish. The push failure now falls back to an image that is actually in the registry, and says so.
|
|
6
|
+
|
|
3
7
|
## 6.5.1 (2026-07-22)
|
|
4
8
|
|
|
5
9
|
* `grok test` — the Node-pass report now merges into the browser report by column name. The line-wise merge assumed identical column order, so node rows landed misaligned (string values in the integer `ms` column) and the CI test-report upload failed with `invalid input syntax for type integer`.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _vitest = require("vitest");
|
|
4
|
+
var _build = require("../commands/build");
|
|
5
|
+
(0, _vitest.describe)('getNestedValue', () => {
|
|
6
|
+
(0, _vitest.it)('returns value for a simple key', () => {
|
|
7
|
+
(0, _vitest.expect)((0, _build.getNestedValue)({
|
|
8
|
+
name: 'Chem'
|
|
9
|
+
}, 'name')).toBe('Chem');
|
|
10
|
+
});
|
|
11
|
+
(0, _vitest.it)('returns value for a nested path', () => {
|
|
12
|
+
(0, _vitest.expect)((0, _build.getNestedValue)({
|
|
13
|
+
a: {
|
|
14
|
+
b: {
|
|
15
|
+
c: 42
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}, 'a.b.c')).toBe(42);
|
|
19
|
+
});
|
|
20
|
+
(0, _vitest.it)('returns undefined for a missing key', () => {
|
|
21
|
+
(0, _vitest.expect)((0, _build.getNestedValue)({
|
|
22
|
+
name: 'Chem'
|
|
23
|
+
}, 'version')).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
(0, _vitest.it)('returns undefined when a mid-path segment is null', () => {
|
|
26
|
+
(0, _vitest.expect)((0, _build.getNestedValue)({
|
|
27
|
+
a: null
|
|
28
|
+
}, 'a.b')).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
(0, _vitest.it)('returns undefined when a mid-path segment is missing', () => {
|
|
31
|
+
(0, _vitest.expect)((0, _build.getNestedValue)({
|
|
32
|
+
a: {}
|
|
33
|
+
}, 'a.b.c')).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
(0, _vitest.it)('returns undefined for an empty path (splits to empty string key)', () => {
|
|
36
|
+
(0, _vitest.expect)((0, _build.getNestedValue)({
|
|
37
|
+
x: 1
|
|
38
|
+
}, '')).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
const pkg = overrides => ({
|
|
42
|
+
dir: '/tmp/pkg',
|
|
43
|
+
name: overrides.name ?? 'test-pkg',
|
|
44
|
+
friendlyName: overrides.friendlyName ?? overrides.name ?? 'Test Pkg',
|
|
45
|
+
version: overrides.version ?? '1.0.0',
|
|
46
|
+
packageJson: overrides
|
|
47
|
+
});
|
|
48
|
+
(0, _vitest.describe)('applyFilter', () => {
|
|
49
|
+
const packages = [pkg({
|
|
50
|
+
name: 'Chem',
|
|
51
|
+
version: '1.5.0',
|
|
52
|
+
category: 'Cheminformatics'
|
|
53
|
+
}), pkg({
|
|
54
|
+
name: 'Bio',
|
|
55
|
+
version: '2.0.0',
|
|
56
|
+
category: 'Bioinformatics'
|
|
57
|
+
}), pkg({
|
|
58
|
+
name: 'PowerGrid',
|
|
59
|
+
version: '1.5.0',
|
|
60
|
+
category: 'Viewers'
|
|
61
|
+
})];
|
|
62
|
+
(0, _vitest.it)('returns all packages when filter matches all', () => {
|
|
63
|
+
(0, _vitest.expect)((0, _build.applyFilter)(packages, 'name:.')).toHaveLength(3);
|
|
64
|
+
});
|
|
65
|
+
(0, _vitest.it)('filters by exact name match', () => {
|
|
66
|
+
const result = (0, _build.applyFilter)(packages, 'name:^Chem$');
|
|
67
|
+
(0, _vitest.expect)(result).toHaveLength(1);
|
|
68
|
+
(0, _vitest.expect)(result[0].name).toBe('Chem');
|
|
69
|
+
});
|
|
70
|
+
(0, _vitest.it)('filters by partial name (regex substring)', () => {
|
|
71
|
+
const result = (0, _build.applyFilter)(packages, 'name:Bio');
|
|
72
|
+
(0, _vitest.expect)(result).toHaveLength(1);
|
|
73
|
+
(0, _vitest.expect)(result[0].name).toBe('Bio');
|
|
74
|
+
});
|
|
75
|
+
(0, _vitest.it)('returns empty array when nothing matches', () => {
|
|
76
|
+
(0, _vitest.expect)((0, _build.applyFilter)(packages, 'name:NOMATCH')).toHaveLength(0);
|
|
77
|
+
});
|
|
78
|
+
(0, _vitest.it)('filters by version', () => {
|
|
79
|
+
const result = (0, _build.applyFilter)(packages, 'version:^1\\.5');
|
|
80
|
+
(0, _vitest.expect)(result).toHaveLength(2);
|
|
81
|
+
(0, _vitest.expect)(result.map(p => p.name)).toEqual(_vitest.expect.arrayContaining(['Chem', 'PowerGrid']));
|
|
82
|
+
});
|
|
83
|
+
(0, _vitest.it)('applies && conjunction (both conditions must match)', () => {
|
|
84
|
+
const result = (0, _build.applyFilter)(packages, 'name:Chem && version:1\\.5');
|
|
85
|
+
(0, _vitest.expect)(result).toHaveLength(1);
|
|
86
|
+
(0, _vitest.expect)(result[0].name).toBe('Chem');
|
|
87
|
+
});
|
|
88
|
+
(0, _vitest.it)('returns empty when one part of && conjunction fails', () => {
|
|
89
|
+
(0, _vitest.expect)((0, _build.applyFilter)(packages, 'name:Chem && version:^2')).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
(0, _vitest.it)('filters by nested field', () => {
|
|
92
|
+
const withNested = [pkg({
|
|
93
|
+
name: 'A',
|
|
94
|
+
datagrok: {
|
|
95
|
+
apiVersion: '1.0'
|
|
96
|
+
}
|
|
97
|
+
}), pkg({
|
|
98
|
+
name: 'B',
|
|
99
|
+
datagrok: {
|
|
100
|
+
apiVersion: '2.0'
|
|
101
|
+
}
|
|
102
|
+
})];
|
|
103
|
+
const result = (0, _build.applyFilter)(withNested, 'datagrok.apiVersion:^1');
|
|
104
|
+
(0, _vitest.expect)(result).toHaveLength(1);
|
|
105
|
+
(0, _vitest.expect)(result[0].name).toBe('A');
|
|
106
|
+
});
|
|
107
|
+
(0, _vitest.it)('returns empty when field does not exist', () => {
|
|
108
|
+
(0, _vitest.expect)((0, _build.applyFilter)(packages, 'nonexistent:anything')).toHaveLength(0);
|
|
109
|
+
});
|
|
110
|
+
(0, _vitest.it)('treats filter with no colon as field name with match-all pattern', () => {
|
|
111
|
+
// No colon → field = whole string, pattern = /./ (matches any value)
|
|
112
|
+
// The function returns packages where the field exists and is non-empty
|
|
113
|
+
const result = (0, _build.applyFilter)(packages, 'name');
|
|
114
|
+
(0, _vitest.expect)(result).toHaveLength(3);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _vitest = require("vitest");
|
|
4
|
+
var _report = require("../commands/report");
|
|
5
|
+
(0, _vitest.describe)('markdownToJiraWiki — basic rules', () => {
|
|
6
|
+
(0, _vitest.it)('converts H1', () => {
|
|
7
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('# Title')).toBe('h1. Title');
|
|
8
|
+
});
|
|
9
|
+
(0, _vitest.it)('converts H2 / H3 / H6', () => {
|
|
10
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('## Sub')).toBe('h2. Sub');
|
|
11
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('### Sub-sub')).toBe('h3. Sub-sub');
|
|
12
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('###### Deep')).toBe('h6. Deep');
|
|
13
|
+
});
|
|
14
|
+
(0, _vitest.it)('converts bold', () => {
|
|
15
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('hello **world** foo')).toBe('hello *world* foo');
|
|
16
|
+
});
|
|
17
|
+
(0, _vitest.it)('converts italic with single asterisks', () => {
|
|
18
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('hello *world* foo')).toBe('hello _world_ foo');
|
|
19
|
+
});
|
|
20
|
+
(0, _vitest.it)('converts strikethrough', () => {
|
|
21
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('~~gone~~')).toBe('-gone-');
|
|
22
|
+
});
|
|
23
|
+
(0, _vitest.it)('converts links', () => {
|
|
24
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('see [docs](https://x.example/y)')).toBe('see [docs|https://x.example/y]');
|
|
25
|
+
});
|
|
26
|
+
(0, _vitest.it)('converts blockquote', () => {
|
|
27
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('> quoted line')).toBe('bq. quoted line');
|
|
28
|
+
});
|
|
29
|
+
(0, _vitest.it)('converts unordered list', () => {
|
|
30
|
+
const md = '- one\n- two\n- three';
|
|
31
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)(md)).toBe('* one\n* two\n* three');
|
|
32
|
+
});
|
|
33
|
+
(0, _vitest.it)('converts one level of nested unordered list', () => {
|
|
34
|
+
const md = '- top\n - sub\n- back';
|
|
35
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)(md)).toBe('* top\n** sub\n* back');
|
|
36
|
+
});
|
|
37
|
+
(0, _vitest.it)('converts ordered list', () => {
|
|
38
|
+
const md = '1. one\n2. two\n3. three';
|
|
39
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)(md)).toBe('# one\n# two\n# three');
|
|
40
|
+
});
|
|
41
|
+
(0, _vitest.it)('converts inline code', () => {
|
|
42
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('use `foo()` here')).toBe('use {{foo()}} here');
|
|
43
|
+
});
|
|
44
|
+
(0, _vitest.it)('converts plain code fence to noformat', () => {
|
|
45
|
+
const md = '```\nraw code\nmore\n```';
|
|
46
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)(md)).toBe('{noformat}\nraw code\nmore\n{noformat}');
|
|
47
|
+
});
|
|
48
|
+
(0, _vitest.it)('converts code fence with language tag to {code:lang}', () => {
|
|
49
|
+
const md = '```js\nconst x = 1;\n```';
|
|
50
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)(md)).toBe('{code:js}\nconst x = 1;\n{code}');
|
|
51
|
+
});
|
|
52
|
+
(0, _vitest.it)('converts HTML entities / & / < / >', () => {
|
|
53
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('a b')).toBe('a b');
|
|
54
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('a&b')).toBe('a&b');
|
|
55
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('a<b>c')).toBe('a<b>c');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
(0, _vitest.describe)('markdownToJiraWiki — content-protection inside code fences', () => {
|
|
59
|
+
(0, _vitest.it)('does not transform `{{plates}}` inside a fenced block (run-#4 false-positive case)', () => {
|
|
60
|
+
const md = ['Before', '```', 'context: {{plates}} should stay literal', '# not a heading', '- not a list', '```', 'After'].join('\n');
|
|
61
|
+
const out = (0, _report.markdownToJiraWiki)(md);
|
|
62
|
+
(0, _vitest.expect)(out).toContain('{noformat}\ncontext: {{plates}} should stay literal');
|
|
63
|
+
(0, _vitest.expect)(out).toContain('# not a heading');
|
|
64
|
+
(0, _vitest.expect)(out).toContain('- not a list');
|
|
65
|
+
(0, _vitest.expect)(out).not.toContain('h1.');
|
|
66
|
+
});
|
|
67
|
+
(0, _vitest.it)('does not transform headings/links/lists inside an inline code span', () => {
|
|
68
|
+
const md = 'use `# not a heading` and `[ref](u)` here';
|
|
69
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)(md)).toBe('use {{# not a heading}} and {{[ref](u)}} here');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
(0, _vitest.describe)('markdownToJiraWiki — edge cases', () => {
|
|
73
|
+
(0, _vitest.it)('handles bold containing italic: **bold *inside* bold**', () => {
|
|
74
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('**bold *inside* bold**')).toBe('*bold _inside_ bold*');
|
|
75
|
+
});
|
|
76
|
+
(0, _vitest.it)('preserves bold and italic on the same line', () => {
|
|
77
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('**a** and *b*')).toBe('*a* and _b_');
|
|
78
|
+
});
|
|
79
|
+
(0, _vitest.it)('handles a heading whose text contains backticks', () => {
|
|
80
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('## use `foo()` for x')).toBe('h2. use {{foo()}} for x');
|
|
81
|
+
});
|
|
82
|
+
(0, _vitest.it)('passes plain text through unchanged', () => {
|
|
83
|
+
const plain = 'Just a normal sentence with no markdown.';
|
|
84
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)(plain)).toBe(plain);
|
|
85
|
+
});
|
|
86
|
+
(0, _vitest.it)('returns empty string unchanged', () => {
|
|
87
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('')).toBe('');
|
|
88
|
+
});
|
|
89
|
+
(0, _vitest.it)('handles a multi-block document end-to-end', () => {
|
|
90
|
+
const md = ['# Handoff', '', 'Some **bold** intro and a [link](https://x.example).', '', '## Findings', '', '- first', '- second', '', '> note: this matters', '', '```js', 'const x = 1;', '```', '', 'Done here.'].join('\n');
|
|
91
|
+
const out = (0, _report.markdownToJiraWiki)(md);
|
|
92
|
+
(0, _vitest.expect)(out).toContain('h1. Handoff');
|
|
93
|
+
(0, _vitest.expect)(out).toContain('h2. Findings');
|
|
94
|
+
(0, _vitest.expect)(out).toContain('Some *bold* intro and a [link|https://x.example].');
|
|
95
|
+
(0, _vitest.expect)(out).toContain('* first\n* second');
|
|
96
|
+
(0, _vitest.expect)(out).toContain('bq. note: this matters');
|
|
97
|
+
(0, _vitest.expect)(out).toContain('{code:js}\nconst x = 1;\n{code}');
|
|
98
|
+
(0, _vitest.expect)(out).toContain('Done here.');
|
|
99
|
+
(0, _vitest.expect)(out).not.toContain(' ');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
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 CONN = {
|
|
32
|
+
name: 'TestPg',
|
|
33
|
+
dataSource: 'PostgreSQL',
|
|
34
|
+
server: 'localhost',
|
|
35
|
+
port: 5432,
|
|
36
|
+
db: 'datagrok'
|
|
37
|
+
};
|
|
38
|
+
(0, _vitest.describe)('NodeConnectionsDataSource.save', () => {
|
|
39
|
+
(0, _vitest.it)('POSTs to /public/v1/connections without saveCredentials by default', async () => {
|
|
40
|
+
const {
|
|
41
|
+
client,
|
|
42
|
+
calls
|
|
43
|
+
} = makeMock((method, path, body) => {
|
|
44
|
+
if (method === 'POST' && path === '/public/v1/connections') return {
|
|
45
|
+
...body,
|
|
46
|
+
id: 'new-id'
|
|
47
|
+
};
|
|
48
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
49
|
+
});
|
|
50
|
+
const ds = new _nodeDapi.NodeConnectionsDataSource(client);
|
|
51
|
+
const saved = await ds.save(CONN);
|
|
52
|
+
(0, _vitest.expect)(saved.id).toBe('new-id');
|
|
53
|
+
(0, _vitest.expect)(calls[0].path).toBe('/public/v1/connections');
|
|
54
|
+
(0, _vitest.expect)(calls[0].body).toEqual(CONN);
|
|
55
|
+
});
|
|
56
|
+
(0, _vitest.it)('appends saveCredentials=true when requested', async () => {
|
|
57
|
+
const {
|
|
58
|
+
client,
|
|
59
|
+
calls
|
|
60
|
+
} = makeMock((method, path, body) => {
|
|
61
|
+
if (method === 'POST' && path === '/public/v1/connections?saveCredentials=true') return {
|
|
62
|
+
...body,
|
|
63
|
+
id: 'new-id'
|
|
64
|
+
};
|
|
65
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
66
|
+
});
|
|
67
|
+
const ds = new _nodeDapi.NodeConnectionsDataSource(client);
|
|
68
|
+
await ds.save(CONN, true);
|
|
69
|
+
(0, _vitest.expect)(calls[0].path).toBe('/public/v1/connections?saveCredentials=true');
|
|
70
|
+
});
|
|
71
|
+
(0, _vitest.it)('omits saveCredentials query param when false', async () => {
|
|
72
|
+
const {
|
|
73
|
+
client,
|
|
74
|
+
calls
|
|
75
|
+
} = makeMock((method, path) => {
|
|
76
|
+
if (method === 'POST' && path === '/public/v1/connections') return {
|
|
77
|
+
id: 'new-id'
|
|
78
|
+
};
|
|
79
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
80
|
+
});
|
|
81
|
+
const ds = new _nodeDapi.NodeConnectionsDataSource(client);
|
|
82
|
+
await ds.save(CONN, false);
|
|
83
|
+
(0, _vitest.expect)(calls[0].path).toBe('/public/v1/connections');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
(0, _vitest.describe)('NodeConnectionsDataSource.test', () => {
|
|
87
|
+
(0, _vitest.it)('POSTs the connection body to /public/v1/connections/test and resolves on "ok"', async () => {
|
|
88
|
+
const {
|
|
89
|
+
client,
|
|
90
|
+
calls
|
|
91
|
+
} = makeMock((method, path) => {
|
|
92
|
+
if (method === 'POST' && path === '/public/v1/connections/test') return 'ok';
|
|
93
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
94
|
+
});
|
|
95
|
+
const ds = new _nodeDapi.NodeConnectionsDataSource(client);
|
|
96
|
+
await ds.test(CONN);
|
|
97
|
+
(0, _vitest.expect)(calls[0].body).toEqual(CONN);
|
|
98
|
+
});
|
|
99
|
+
(0, _vitest.it)('strips surrounding quotes on a JSON-encoded "ok"', async () => {
|
|
100
|
+
const {
|
|
101
|
+
client
|
|
102
|
+
} = makeMock(() => '"ok"');
|
|
103
|
+
const ds = new _nodeDapi.NodeConnectionsDataSource(client);
|
|
104
|
+
await (0, _vitest.expect)(ds.test(CONN)).resolves.toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
(0, _vitest.it)('throws the server error text when the response is not ok', async () => {
|
|
107
|
+
const {
|
|
108
|
+
client
|
|
109
|
+
} = makeMock(() => 'connection refused: tcp://localhost:5432');
|
|
110
|
+
const ds = new _nodeDapi.NodeConnectionsDataSource(client);
|
|
111
|
+
await (0, _vitest.expect)(ds.test(CONN)).rejects.toThrow(/connection refused/);
|
|
112
|
+
});
|
|
113
|
+
(0, _vitest.it)('throws a generic message on empty response', async () => {
|
|
114
|
+
const {
|
|
115
|
+
client
|
|
116
|
+
} = makeMock(() => '');
|
|
117
|
+
const ds = new _nodeDapi.NodeConnectionsDataSource(client);
|
|
118
|
+
await (0, _vitest.expect)(ds.test(CONN)).rejects.toThrow(/failed/i);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,467 @@
|
|
|
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 PARENT_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
|
32
|
+
const ALICE_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
|
33
|
+
const BOB_ID = 'cccccccc-cccc-cccc-cccc-cccccccccccc';
|
|
34
|
+
function makeParent(children = []) {
|
|
35
|
+
return {
|
|
36
|
+
id: PARENT_ID,
|
|
37
|
+
friendlyName: 'Admins',
|
|
38
|
+
children
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
(0, _vitest.describe)('NodeGroupsDataSource.resolve', () => {
|
|
42
|
+
(0, _vitest.it)('treats a UUID as a direct ID and calls find()', async () => {
|
|
43
|
+
const {
|
|
44
|
+
client,
|
|
45
|
+
calls
|
|
46
|
+
} = makeMock((_m, path) => {
|
|
47
|
+
if (path.startsWith(`/public/v1/groups/${PARENT_ID}`)) return makeParent();
|
|
48
|
+
throw new Error(`unexpected ${path}`);
|
|
49
|
+
});
|
|
50
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
51
|
+
const g = await ds.resolve(PARENT_ID);
|
|
52
|
+
(0, _vitest.expect)(g.id).toBe(PARENT_ID);
|
|
53
|
+
(0, _vitest.expect)(calls[0].path).toBe(`/public/v1/groups/${PARENT_ID}`);
|
|
54
|
+
});
|
|
55
|
+
(0, _vitest.it)('uses /lookup for non-UUID names and returns the unique match', async () => {
|
|
56
|
+
const {
|
|
57
|
+
client,
|
|
58
|
+
calls
|
|
59
|
+
} = makeMock((_m, path) => {
|
|
60
|
+
if (path.startsWith('/public/v1/groups/lookup')) return [{
|
|
61
|
+
id: ALICE_ID,
|
|
62
|
+
name: 'alice',
|
|
63
|
+
friendlyName: 'alice',
|
|
64
|
+
personal: true
|
|
65
|
+
}];
|
|
66
|
+
throw new Error(`unexpected ${path}`);
|
|
67
|
+
});
|
|
68
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
69
|
+
const g = await ds.resolve('alice');
|
|
70
|
+
(0, _vitest.expect)(g.id).toBe(ALICE_ID);
|
|
71
|
+
(0, _vitest.expect)(calls[0].path).toBe('/public/v1/groups/lookup?query=alice');
|
|
72
|
+
});
|
|
73
|
+
(0, _vitest.it)('errors with a list when lookup returns multiple matches', async () => {
|
|
74
|
+
const {
|
|
75
|
+
client
|
|
76
|
+
} = makeMock((_m, path) => {
|
|
77
|
+
if (path.startsWith('/public/v1/groups/lookup')) return [{
|
|
78
|
+
id: 'g1',
|
|
79
|
+
friendlyName: 'alice',
|
|
80
|
+
personal: false
|
|
81
|
+
}, {
|
|
82
|
+
id: 'g2',
|
|
83
|
+
friendlyName: 'alice',
|
|
84
|
+
personal: true
|
|
85
|
+
}];
|
|
86
|
+
throw new Error(`unexpected ${path}`);
|
|
87
|
+
});
|
|
88
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
89
|
+
await (0, _vitest.expect)(ds.resolve('alice')).rejects.toThrow(/Multiple groups match 'alice'/);
|
|
90
|
+
});
|
|
91
|
+
(0, _vitest.it)('personalOnly filters lookup results to personal groups', async () => {
|
|
92
|
+
const {
|
|
93
|
+
client
|
|
94
|
+
} = makeMock((_m, path) => {
|
|
95
|
+
if (path.startsWith('/public/v1/groups/lookup')) return [{
|
|
96
|
+
id: 'g1',
|
|
97
|
+
friendlyName: 'alice',
|
|
98
|
+
personal: false
|
|
99
|
+
}, {
|
|
100
|
+
id: ALICE_ID,
|
|
101
|
+
friendlyName: 'alice',
|
|
102
|
+
personal: true
|
|
103
|
+
}];
|
|
104
|
+
throw new Error(`unexpected ${path}`);
|
|
105
|
+
});
|
|
106
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
107
|
+
const g = await ds.resolve('alice', {
|
|
108
|
+
personalOnly: true
|
|
109
|
+
});
|
|
110
|
+
(0, _vitest.expect)(g.id).toBe(ALICE_ID);
|
|
111
|
+
});
|
|
112
|
+
(0, _vitest.it)('errors when personalOnly yields no matches', async () => {
|
|
113
|
+
const {
|
|
114
|
+
client
|
|
115
|
+
} = makeMock((_m, path) => {
|
|
116
|
+
if (path.startsWith('/public/v1/groups/lookup')) return [{
|
|
117
|
+
id: 'g1',
|
|
118
|
+
friendlyName: 'alice',
|
|
119
|
+
personal: false
|
|
120
|
+
}];
|
|
121
|
+
throw new Error(`unexpected ${path}`);
|
|
122
|
+
});
|
|
123
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
124
|
+
await (0, _vitest.expect)(ds.resolve('alice', {
|
|
125
|
+
personalOnly: true
|
|
126
|
+
})).rejects.toThrow(/No group matching 'alice' \(personal\)/);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
(0, _vitest.describe)('NodeGroupsDataSource.save', () => {
|
|
130
|
+
(0, _vitest.it)('POSTs the group body to /public/v1/groups and auto-generates an id when missing', async () => {
|
|
131
|
+
const {
|
|
132
|
+
client,
|
|
133
|
+
calls
|
|
134
|
+
} = makeMock((method, path, body) => {
|
|
135
|
+
if (method === 'POST' && path === '/public/v1/groups') return {
|
|
136
|
+
...body,
|
|
137
|
+
friendlyName: 'Chemists'
|
|
138
|
+
};
|
|
139
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
140
|
+
});
|
|
141
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
142
|
+
await ds.save({
|
|
143
|
+
friendlyName: 'Chemists'
|
|
144
|
+
});
|
|
145
|
+
(0, _vitest.expect)(calls[0].path).toBe('/public/v1/groups');
|
|
146
|
+
(0, _vitest.expect)(calls[0].body.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
|
147
|
+
});
|
|
148
|
+
(0, _vitest.it)('appends saveRelations=true when requested', async () => {
|
|
149
|
+
const {
|
|
150
|
+
client,
|
|
151
|
+
calls
|
|
152
|
+
} = makeMock((method, path) => {
|
|
153
|
+
if (method === 'POST' && path === '/public/v1/groups?saveRelations=true') return {
|
|
154
|
+
id: 'g'
|
|
155
|
+
};
|
|
156
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
157
|
+
});
|
|
158
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
159
|
+
await ds.save({
|
|
160
|
+
id: 'g',
|
|
161
|
+
friendlyName: 'Chemists'
|
|
162
|
+
}, true);
|
|
163
|
+
(0, _vitest.expect)(calls[0].path).toBe('/public/v1/groups?saveRelations=true');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
(0, _vitest.describe)('NodeGroupsDataSource.addMembers', () => {
|
|
167
|
+
(0, _vitest.it)('appends new relations and POSTs with saveRelations=true', async () => {
|
|
168
|
+
const {
|
|
169
|
+
client,
|
|
170
|
+
calls
|
|
171
|
+
} = makeMock((method, path, _body) => {
|
|
172
|
+
if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
|
|
173
|
+
if (method === 'GET' && path.startsWith('/public/v1/groups/lookup')) return [{
|
|
174
|
+
id: ALICE_ID,
|
|
175
|
+
friendlyName: 'alice',
|
|
176
|
+
personal: true
|
|
177
|
+
}];
|
|
178
|
+
if (method === 'POST' && path === '/public/v1/groups?saveRelations=true') return {
|
|
179
|
+
id: PARENT_ID
|
|
180
|
+
};
|
|
181
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
182
|
+
});
|
|
183
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
184
|
+
const results = await ds.addMembers(PARENT_ID, ['alice']);
|
|
185
|
+
(0, _vitest.expect)(results).toEqual([{
|
|
186
|
+
member: 'alice',
|
|
187
|
+
status: 'added'
|
|
188
|
+
}]);
|
|
189
|
+
const post = calls.find(c => c.method === 'POST');
|
|
190
|
+
(0, _vitest.expect)(post.path).toBe('/public/v1/groups?saveRelations=true');
|
|
191
|
+
(0, _vitest.expect)(post.body.children).toEqual([{
|
|
192
|
+
parent: {
|
|
193
|
+
id: PARENT_ID
|
|
194
|
+
},
|
|
195
|
+
child: {
|
|
196
|
+
id: ALICE_ID
|
|
197
|
+
},
|
|
198
|
+
isAdmin: false
|
|
199
|
+
}]);
|
|
200
|
+
});
|
|
201
|
+
(0, _vitest.it)('supports --admin by setting isAdmin on the new relation', async () => {
|
|
202
|
+
const {
|
|
203
|
+
client,
|
|
204
|
+
calls
|
|
205
|
+
} = makeMock((method, path) => {
|
|
206
|
+
if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
|
|
207
|
+
if (method === 'GET' && path.startsWith('/public/v1/groups/lookup')) return [{
|
|
208
|
+
id: ALICE_ID,
|
|
209
|
+
friendlyName: 'alice',
|
|
210
|
+
personal: true
|
|
211
|
+
}];
|
|
212
|
+
if (method === 'POST') return {
|
|
213
|
+
id: PARENT_ID
|
|
214
|
+
};
|
|
215
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
216
|
+
});
|
|
217
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
218
|
+
await ds.addMembers(PARENT_ID, ['alice'], true);
|
|
219
|
+
const post = calls.find(c => c.method === 'POST');
|
|
220
|
+
(0, _vitest.expect)(post.body.children[0].isAdmin).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
(0, _vitest.it)('is idempotent: re-adding with the same role reports noop and skips POST', async () => {
|
|
223
|
+
const existing = {
|
|
224
|
+
parent: {
|
|
225
|
+
id: PARENT_ID
|
|
226
|
+
},
|
|
227
|
+
child: {
|
|
228
|
+
id: ALICE_ID
|
|
229
|
+
},
|
|
230
|
+
isAdmin: false
|
|
231
|
+
};
|
|
232
|
+
const {
|
|
233
|
+
client,
|
|
234
|
+
calls
|
|
235
|
+
} = makeMock((method, path) => {
|
|
236
|
+
if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent([existing]);
|
|
237
|
+
if (method === 'GET' && path.startsWith('/public/v1/groups/lookup')) return [{
|
|
238
|
+
id: ALICE_ID,
|
|
239
|
+
friendlyName: 'alice',
|
|
240
|
+
personal: true
|
|
241
|
+
}];
|
|
242
|
+
if (method === 'POST') return {
|
|
243
|
+
id: PARENT_ID
|
|
244
|
+
};
|
|
245
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
246
|
+
});
|
|
247
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
248
|
+
const results = await ds.addMembers(PARENT_ID, ['alice']);
|
|
249
|
+
(0, _vitest.expect)(results).toEqual([{
|
|
250
|
+
member: 'alice',
|
|
251
|
+
status: 'noop'
|
|
252
|
+
}]);
|
|
253
|
+
(0, _vitest.expect)(calls.find(c => c.method === 'POST')).toBeUndefined();
|
|
254
|
+
});
|
|
255
|
+
(0, _vitest.it)('flips isAdmin when re-adding an existing member with a different role', async () => {
|
|
256
|
+
const existing = {
|
|
257
|
+
parent: {
|
|
258
|
+
id: PARENT_ID
|
|
259
|
+
},
|
|
260
|
+
child: {
|
|
261
|
+
id: ALICE_ID
|
|
262
|
+
},
|
|
263
|
+
isAdmin: false
|
|
264
|
+
};
|
|
265
|
+
const {
|
|
266
|
+
client,
|
|
267
|
+
calls
|
|
268
|
+
} = makeMock((method, path) => {
|
|
269
|
+
if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent([existing]);
|
|
270
|
+
if (method === 'GET' && path.startsWith('/public/v1/groups/lookup')) return [{
|
|
271
|
+
id: ALICE_ID,
|
|
272
|
+
friendlyName: 'alice',
|
|
273
|
+
personal: true
|
|
274
|
+
}];
|
|
275
|
+
if (method === 'POST') return {
|
|
276
|
+
id: PARENT_ID
|
|
277
|
+
};
|
|
278
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
279
|
+
});
|
|
280
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
281
|
+
const results = await ds.addMembers(PARENT_ID, ['alice'], true);
|
|
282
|
+
(0, _vitest.expect)(results).toEqual([{
|
|
283
|
+
member: 'alice',
|
|
284
|
+
status: 'updated'
|
|
285
|
+
}]);
|
|
286
|
+
const post = calls.find(c => c.method === 'POST');
|
|
287
|
+
(0, _vitest.expect)(post.body.children).toHaveLength(1);
|
|
288
|
+
(0, _vitest.expect)(post.body.children[0].isAdmin).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
(0, _vitest.it)('processes a mixed batch: one valid, one unresolvable', async () => {
|
|
291
|
+
const {
|
|
292
|
+
client,
|
|
293
|
+
calls
|
|
294
|
+
} = makeMock((method, path) => {
|
|
295
|
+
if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
|
|
296
|
+
if (method === 'GET' && path === '/public/v1/groups/lookup?query=alice') return [{
|
|
297
|
+
id: ALICE_ID,
|
|
298
|
+
friendlyName: 'alice',
|
|
299
|
+
personal: true
|
|
300
|
+
}];
|
|
301
|
+
if (method === 'GET' && path === '/public/v1/groups/lookup?query=nobody') return [];
|
|
302
|
+
if (method === 'POST') return {
|
|
303
|
+
id: PARENT_ID
|
|
304
|
+
};
|
|
305
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
306
|
+
});
|
|
307
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
308
|
+
const results = await ds.addMembers(PARENT_ID, ['alice', 'nobody']);
|
|
309
|
+
(0, _vitest.expect)(results[0]).toEqual({
|
|
310
|
+
member: 'alice',
|
|
311
|
+
status: 'added'
|
|
312
|
+
});
|
|
313
|
+
(0, _vitest.expect)(results[1].member).toBe('nobody');
|
|
314
|
+
(0, _vitest.expect)(results[1].status).toBe('error');
|
|
315
|
+
(0, _vitest.expect)(results[1].error).toMatch(/No group matching 'nobody'/);
|
|
316
|
+
// POST should still happen for the valid member
|
|
317
|
+
const post = calls.find(c => c.method === 'POST');
|
|
318
|
+
(0, _vitest.expect)(post.body.children).toHaveLength(1);
|
|
319
|
+
(0, _vitest.expect)(post.body.children[0].child.id).toBe(ALICE_ID);
|
|
320
|
+
});
|
|
321
|
+
(0, _vitest.it)('handles multiple members in a single call', async () => {
|
|
322
|
+
const {
|
|
323
|
+
client,
|
|
324
|
+
calls
|
|
325
|
+
} = makeMock((method, path) => {
|
|
326
|
+
if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
|
|
327
|
+
if (method === 'GET' && path === '/public/v1/groups/lookup?query=alice') return [{
|
|
328
|
+
id: ALICE_ID,
|
|
329
|
+
friendlyName: 'alice',
|
|
330
|
+
personal: true
|
|
331
|
+
}];
|
|
332
|
+
if (method === 'GET' && path === '/public/v1/groups/lookup?query=bob') return [{
|
|
333
|
+
id: BOB_ID,
|
|
334
|
+
friendlyName: 'bob',
|
|
335
|
+
personal: true
|
|
336
|
+
}];
|
|
337
|
+
if (method === 'POST') return {
|
|
338
|
+
id: PARENT_ID
|
|
339
|
+
};
|
|
340
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
341
|
+
});
|
|
342
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
343
|
+
const results = await ds.addMembers(PARENT_ID, ['alice', 'bob']);
|
|
344
|
+
(0, _vitest.expect)(results.map(r => r.status)).toEqual(['added', 'added']);
|
|
345
|
+
const post = calls.find(c => c.method === 'POST');
|
|
346
|
+
(0, _vitest.expect)(post.body.children.map(c => c.child.id)).toEqual([ALICE_ID, BOB_ID]);
|
|
347
|
+
// exactly one POST regardless of batch size
|
|
348
|
+
(0, _vitest.expect)(calls.filter(c => c.method === 'POST')).toHaveLength(1);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
(0, _vitest.describe)('NodeGroupsDataSource.removeMembers', () => {
|
|
352
|
+
(0, _vitest.it)('filters the relation out and POSTs with saveRelations=true', async () => {
|
|
353
|
+
const existing = {
|
|
354
|
+
parent: {
|
|
355
|
+
id: PARENT_ID
|
|
356
|
+
},
|
|
357
|
+
child: {
|
|
358
|
+
id: ALICE_ID
|
|
359
|
+
},
|
|
360
|
+
isAdmin: false
|
|
361
|
+
};
|
|
362
|
+
const {
|
|
363
|
+
client,
|
|
364
|
+
calls
|
|
365
|
+
} = makeMock((method, path) => {
|
|
366
|
+
if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent([existing]);
|
|
367
|
+
if (method === 'GET' && path.startsWith('/public/v1/groups/lookup')) return [{
|
|
368
|
+
id: ALICE_ID,
|
|
369
|
+
friendlyName: 'alice',
|
|
370
|
+
personal: true
|
|
371
|
+
}];
|
|
372
|
+
if (method === 'POST') return {
|
|
373
|
+
id: PARENT_ID
|
|
374
|
+
};
|
|
375
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
376
|
+
});
|
|
377
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
378
|
+
const results = await ds.removeMembers(PARENT_ID, ['alice']);
|
|
379
|
+
(0, _vitest.expect)(results).toEqual([{
|
|
380
|
+
member: 'alice',
|
|
381
|
+
status: 'removed'
|
|
382
|
+
}]);
|
|
383
|
+
const post = calls.find(c => c.method === 'POST');
|
|
384
|
+
(0, _vitest.expect)(post.path).toBe('/public/v1/groups?saveRelations=true');
|
|
385
|
+
(0, _vitest.expect)(post.body.children).toEqual([]);
|
|
386
|
+
});
|
|
387
|
+
(0, _vitest.it)('reports not-member and skips POST when the member isn\'t in the group', async () => {
|
|
388
|
+
const {
|
|
389
|
+
client,
|
|
390
|
+
calls
|
|
391
|
+
} = makeMock((method, path) => {
|
|
392
|
+
if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
|
|
393
|
+
if (method === 'GET' && path.startsWith('/public/v1/groups/lookup')) return [{
|
|
394
|
+
id: ALICE_ID,
|
|
395
|
+
friendlyName: 'alice',
|
|
396
|
+
personal: true
|
|
397
|
+
}];
|
|
398
|
+
if (method === 'POST') return {
|
|
399
|
+
id: PARENT_ID
|
|
400
|
+
};
|
|
401
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
402
|
+
});
|
|
403
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
404
|
+
const results = await ds.removeMembers(PARENT_ID, ['alice']);
|
|
405
|
+
(0, _vitest.expect)(results).toEqual([{
|
|
406
|
+
member: 'alice',
|
|
407
|
+
status: 'not-member'
|
|
408
|
+
}]);
|
|
409
|
+
(0, _vitest.expect)(calls.find(c => c.method === 'POST')).toBeUndefined();
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
(0, _vitest.describe)('NodeGroupsDataSource.getMembers', () => {
|
|
413
|
+
(0, _vitest.it)('calls /members without an admin query when admin is undefined', async () => {
|
|
414
|
+
const {
|
|
415
|
+
client,
|
|
416
|
+
calls
|
|
417
|
+
} = makeMock((method, path) => {
|
|
418
|
+
if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
|
|
419
|
+
if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}/members`) return [];
|
|
420
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
421
|
+
});
|
|
422
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
423
|
+
await ds.getMembers(PARENT_ID);
|
|
424
|
+
(0, _vitest.expect)(calls.map(c => c.path)).toContain(`/public/v1/groups/${PARENT_ID}/members`);
|
|
425
|
+
});
|
|
426
|
+
(0, _vitest.it)('passes admin=true', async () => {
|
|
427
|
+
const {
|
|
428
|
+
client,
|
|
429
|
+
calls
|
|
430
|
+
} = makeMock((method, path) => {
|
|
431
|
+
if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
|
|
432
|
+
if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}/members?admin=true`) return [];
|
|
433
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
434
|
+
});
|
|
435
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
436
|
+
await ds.getMembers(PARENT_ID, true);
|
|
437
|
+
(0, _vitest.expect)(calls.map(c => c.path)).toContain(`/public/v1/groups/${PARENT_ID}/members?admin=true`);
|
|
438
|
+
});
|
|
439
|
+
(0, _vitest.it)('passes admin=false', async () => {
|
|
440
|
+
const {
|
|
441
|
+
client,
|
|
442
|
+
calls
|
|
443
|
+
} = makeMock((method, path) => {
|
|
444
|
+
if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
|
|
445
|
+
if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}/members?admin=false`) return [];
|
|
446
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
447
|
+
});
|
|
448
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
449
|
+
await ds.getMembers(PARENT_ID, false);
|
|
450
|
+
(0, _vitest.expect)(calls.map(c => c.path)).toContain(`/public/v1/groups/${PARENT_ID}/members?admin=false`);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
(0, _vitest.describe)('NodeGroupsDataSource.getMemberships', () => {
|
|
454
|
+
(0, _vitest.it)('calls /memberships with admin flag when provided', async () => {
|
|
455
|
+
const {
|
|
456
|
+
client,
|
|
457
|
+
calls
|
|
458
|
+
} = makeMock((method, path) => {
|
|
459
|
+
if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
|
|
460
|
+
if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}/memberships?admin=true`) return [];
|
|
461
|
+
throw new Error(`unexpected ${method} ${path}`);
|
|
462
|
+
});
|
|
463
|
+
const ds = new _nodeDapi.NodeGroupsDataSource(client);
|
|
464
|
+
await ds.getMemberships(PARENT_ID, true);
|
|
465
|
+
(0, _vitest.expect)(calls.map(c => c.path)).toContain(`/public/v1/groups/${PARENT_ID}/memberships?admin=true`);
|
|
466
|
+
});
|
|
467
|
+
});
|
package/bin/commands/publish.js
CHANGED
|
@@ -343,6 +343,11 @@ async function processDockerImages(packageName, version, registry, devKey, host,
|
|
|
343
343
|
color.log(` Tagged as ${canonicalTag}`);
|
|
344
344
|
}
|
|
345
345
|
result = pushImage(img.imageName, registryTag, registry);
|
|
346
|
+
if (!result) {
|
|
347
|
+
const fallback = await fallbackImage(img, host, devKey, registry, version, contentHash);
|
|
348
|
+
if (fallback.serverError) color.error(`Cannot resolve fallback: ${fallback.serverError}`);else if (fallback.image) color.warn(`Falling back to ${fallback.image}` + `${fallback.hashMatch === false ? ' (hash mismatch — container will run older code)' : ''}`);else color.error(`No published image available for ${img.imageName}. No container will be available.`);
|
|
349
|
+
result = fallback;
|
|
350
|
+
}
|
|
346
351
|
} else {
|
|
347
352
|
color.warn(`Local image not found. Expected: ${img.fullLocalName}`);
|
|
348
353
|
color.log(` Build it with: docker build -t ${img.fullLocalName} -f ${dockerfilePath} ${dockerfileDir}`);
|
|
@@ -364,7 +369,7 @@ async function processDockerImages(packageName, version, registry, devKey, host,
|
|
|
364
369
|
fallback: true,
|
|
365
370
|
requestedVersion: registryTag
|
|
366
371
|
};
|
|
367
|
-
if (!result || result.fallback) color.warn(`
|
|
372
|
+
if (!result || result.fallback) color.warn(`Could not publish a new image. Falling back to ${fallback.image} (hash mismatch)`);
|
|
368
373
|
} else {
|
|
369
374
|
// The server has no compatible record, but the image may already be
|
|
370
375
|
// published in the configured registry / Docker Hub (e.g. pushed by an
|
|
@@ -394,7 +399,7 @@ async function processDockerImages(packageName, version, registry, devKey, host,
|
|
|
394
399
|
fallback: true,
|
|
395
400
|
requestedVersion: registryTag
|
|
396
401
|
};
|
|
397
|
-
color.error(`Failed to
|
|
402
|
+
color.error(`Failed to publish ${img.fullLocalName}. No container will be available.`);
|
|
398
403
|
}
|
|
399
404
|
}
|
|
400
405
|
}
|
|
@@ -408,6 +413,12 @@ async function processDockerImages(packageName, version, registry, devKey, host,
|
|
|
408
413
|
color.log(`Added ${imageJsonPath}`);
|
|
409
414
|
}
|
|
410
415
|
}
|
|
416
|
+
|
|
417
|
+
// Returns null when the push failed. Never claim a tag that was not published: the server
|
|
418
|
+
// records it as the container's image and the spawner then fails validation forever
|
|
419
|
+
// ("not found in any registry") with no way back except another publish.
|
|
420
|
+
// With no registry configured the local tag is all there is — dev stacks share the host
|
|
421
|
+
// docker daemon and the spawner resolves local images, so that stays a warning.
|
|
411
422
|
function pushImage(imageName, tag, registry) {
|
|
412
423
|
const canonicalImage = `datagrok/${imageName}:${tag}`;
|
|
413
424
|
if (registry) {
|
|
@@ -417,11 +428,8 @@ function pushImage(imageName, tag, registry) {
|
|
|
417
428
|
image: canonicalImage,
|
|
418
429
|
fallback: false
|
|
419
430
|
};
|
|
420
|
-
color.
|
|
421
|
-
return
|
|
422
|
-
image: canonicalImage,
|
|
423
|
-
fallback: false
|
|
424
|
-
};
|
|
431
|
+
color.error(`${remoteTag} was not published — check \`docker login ${registry}\` and your developer key.`);
|
|
432
|
+
return null;
|
|
425
433
|
}
|
|
426
434
|
color.warn(`No registry configured. Image tagged locally only. Run \`grok config --registry\` to configure.`);
|
|
427
435
|
return {
|
|
@@ -50,6 +50,9 @@ async function run(config, args) {
|
|
|
50
50
|
processArgs.push('src/package-test-node.ts');
|
|
51
51
|
processArgs.push(`--apiUrl=${config.url}`);
|
|
52
52
|
processArgs.push(`--devKey=${config.key}`);
|
|
53
|
+
// Explicit even though it's the runner default: the stress baseline must only run
|
|
54
|
+
// stressTest-marked tests regardless of how the runner's default evolves.
|
|
55
|
+
processArgs.push('--mode=stress');
|
|
53
56
|
if (args["concurrent-runs"]) processArgs.push(`--concurrentRuns=${args["concurrent-runs"]}`);
|
|
54
57
|
if (args['loop']) processArgs.push(`--loop`);
|
|
55
58
|
if (args['concurrency-range']) processArgs.push(`--concurrencyRange=${args["concurrency-range"]}`);
|
|
@@ -8,12 +8,20 @@ export { tests };
|
|
|
8
8
|
//input: string category {optional: true}
|
|
9
9
|
//input: string test {optional: true}
|
|
10
10
|
//input: object testContext {optional: true}
|
|
11
|
+
//input: bool excludeNodeTests {optional: true}
|
|
11
12
|
//output: dataframe result
|
|
12
|
-
export async function test(category: string, test: string, testContext: TestContext): Promise<DG.DataFrame> {
|
|
13
|
-
const data = await runTests({ category, test, testContext });
|
|
13
|
+
export async function test(category: string, test: string, testContext: TestContext, excludeNodeTests?: boolean): Promise<DG.DataFrame> {
|
|
14
|
+
const data = await runTests({ category, test, testContext, excludeNodeTests });
|
|
14
15
|
return DG.DataFrame.fromObjects(data)!;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
/** Headless entry for the `grok test` Node pass — runs only tests marked {node: true}. */
|
|
19
|
+
export async function testNode(pkg: DG.Package,
|
|
20
|
+
options: {category?: string, test?: string, stressTest?: boolean, verbose?: boolean}): Promise<any[]> {
|
|
21
|
+
return await runTests({category: options.category, test: options.test, stressTest: options.stressTest,
|
|
22
|
+
verbose: options.verbose, nodeOnly: true, nodeOptions: {package: pkg}});
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
//name: initAutoTests
|
|
18
26
|
export async function initAutoTests() {
|
|
19
27
|
await initTests(_package, _package.getModule('package-test.js'));
|
package/package.json
CHANGED