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 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&lt;b&gt;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&nbsp;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('&nbsp;');
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
+ });
@@ -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(`Build failed. Falling back to ${fallback.image} (hash mismatch)`);
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 build ${img.fullLocalName}. No container will be available.`);
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.warn(`Push failed, image tagged locally only: ${remoteTag}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "datagrok-tools",
3
- "version": "6.5.1",
3
+ "version": "6.5.2",
4
4
  "description": "Utility to upload and publish packages to Datagrok",
5
5
  "homepage": "https://github.com/datagrok-ai/public/tree/master/tools#readme",
6
6
  "dependencies": {