datagrok-tools 6.5.0 → 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,41 +1,49 @@
1
1
  # Datagrok-tools changelog
2
2
 
3
- ## 6.5.0 (2026-07-22)
3
+ ## 6.5.2 (2026-07-27)
4
4
 
5
- * `grok test` ??? added a Node (browserless) pass: tests annotated `{node: true}` run headless under the js-api Node runtime before the browser launches; the browser pass excludes them and is skipped entirely when nothing browser-only matches. New flags: `--skip-node`, `--node-only`. Packages opt in by exporting `testNode()` from `package-test.ts`; others keep the previous behavior.
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
+
7
+ ## 6.5.1 (2026-07-22)
8
+
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`.
10
+
11
+ ## 6.5.0 (WIP)
12
+
13
+ * `grok test` — added a Node (browserless) pass: tests annotated `{node: true}` run headless under the js-api Node runtime before the browser launches; the browser pass excludes them and is skipped entirely when nothing browser-only matches. New flags: `--skip-node`, `--node-only`. Packages opt in by exporting `testNode()` from `package-test.ts`; others keep the previous behavior.
6
14
 
7
15
  ## 6.4.8 (2026-07-22)
8
16
 
9
- * GROK-20452: `grok publish` ??? always `docker build` the tools-generated worker dirs (`dockerfiles/queue`, `dockerfiles/celery`, `dockerfiles/<app>-celery`) instead of reusing a cached local tag. The "found local image" shortcut froze the worker at whatever stock base the daemon had when the tag was first built (CI ran day-old worker code with a fresh base available); the layer cache keeps an unchanged rebuild near-instant.
10
- * `grok test` ??? `--no-retry` is now honored for Playwright runs. minimist parsed `--no-retry` as `{retry:false}`, so the flag was silently dropped and failed specs were still retried once; normalized so `--retries=0` reaches Playwright.
17
+ * GROK-20452: `grok publish` always `docker build` the tools-generated worker dirs (`dockerfiles/queue`, `dockerfiles/celery`, `dockerfiles/<app>-celery`) instead of reusing a cached local tag. The "found local image" shortcut froze the worker at whatever stock base the daemon had when the tag was first built (CI ran day-old worker code with a fresh base available); the layer cache keeps an unchanged rebuild near-instant.
18
+ * `grok test` `--no-retry` is now honored for Playwright runs. minimist parsed `--no-retry` as `{retry:false}`, so the flag was silently dropped and failed specs were still retried once; normalized so `--retries=0` reaches Playwright.
11
19
 
12
20
  ## 6.4.7 (2026-07-22)
13
21
 
14
- * GROK-20452: `grok publish` ??? generate `dockerfiles/celery` / `dockerfiles/queue` **before** gathering files for the zip. They were generated after, so the generated Dockerfile never reached the server: `meta.queue` functions fell back to a server-side container named `<pkg>-queue-celery` while the client had built `<pkg>-queue` ??? image validation 404'd and the worker container never started ("Container is not started" on every call).
22
+ * GROK-20452: `grok publish` generate `dockerfiles/celery` / `dockerfiles/queue` **before** gathering files for the zip. They were generated after, so the generated Dockerfile never reached the server: `meta.queue` functions fell back to a server-side container named `<pkg>-queue-celery` while the client had built `<pkg>-queue` image validation 404'd and the worker container never started ("Container is not started" on every call).
15
23
 
16
24
  ## 6.4.6 (2026-07-22)
17
25
 
18
- * `grok test` ??? the whole-run Puppeteer cap (60 min) is now overridable via `GROK_TEST_INVOCATION_TIMEOUT_MS`. When the cap fires mid-pass all collected results are discarded (empty `test-report.csv`, "Passed tests: 0"), which CI reports as "no results produced"; loaded CI agents can now raise the cap instead.
26
+ * `grok test` the whole-run Puppeteer cap (60 min) is now overridable via `GROK_TEST_INVOCATION_TIMEOUT_MS`. When the cap fires mid-pass all collected results are discarded (empty `test-report.csv`, "Passed tests: 0"), which CI reports as "no results produced"; loaded CI agents can now raise the cap instead.
19
27
 
20
28
  ## 6.4.5 (2026-06-23)
21
29
 
22
- * `grok test` ??? screen recording (`--record`) is now optional: if `page.screencast()` can't start (e.g. `ffmpeg` is missing on the runner, `spawnSync ffmpeg ENOENT`), the run warns and continues instead of failing the whole Puppeteer pass with 0 tests executed.
30
+ * `grok test` screen recording (`--record`) is now optional: if `page.screencast()` can't start (e.g. `ffmpeg` is missing on the runner, `spawnSync ffmpeg ENOENT`), the run warns and continues instead of failing the whole Puppeteer pass with 0 tests executed.
23
31
 
24
32
  ## 6.4.4 (2026-06-22)
25
33
 
26
- * `grok publish` ??? fixed every publish failing with a silent `exit 1` after a successful upload: a stray `fs.unlinkSync('zip')` threw `ENOENT` (the archive is streamed in-memory, no `zip` file is ever written), and the surrounding `catch` only logged under `--verbose`. The errant unlink is removed and publish errors are now always surfaced.
34
+ * `grok publish` fixed every publish failing with a silent `exit 1` after a successful upload: a stray `fs.unlinkSync('zip')` threw `ENOENT` (the archive is streamed in-memory, no `zip` file is ever written), and the surrounding `catch` only logged under `--verbose`. The errant unlink is removed and publish errors are now always surfaced.
27
35
 
28
36
  ## 6.4.3 (2026-06-22)
29
37
 
30
- * `grok api` ??? numeric IVP model inputs are now generated with `nullable: false`, so an emptied input field fails form validation instead of running the solver with a null.
38
+ * `grok api` numeric IVP model inputs are now generated with `nullable: false`, so an emptied input field fails form validation instead of running the solver with a null.
31
39
 
32
40
  ## 6.4.2 (2026-06-18)
33
41
 
34
- * `grok publish` ??? registry-aware Docker fallback: when a package's image isn't built locally and the target server has no compatible record, `grok publish` now checks the configured registry and Docker Hub (`docker manifest inspect`) for the expected `datagrok/<name>:<version>` (and content-hashed) tag and uses it, instead of reporting "No fallback available" and failing. Fixes dependency publishes (e.g. Bio ??? @datagrok/chem) on CI runners where the image exists in the registry but not locally.
42
+ * `grok publish` registry-aware Docker fallback: when a package's image isn't built locally and the target server has no compatible record, `grok publish` now checks the configured registry and Docker Hub (`docker manifest inspect`) for the expected `datagrok/<name>:<version>` (and content-hashed) tag and uses it, instead of reporting "No fallback available" and failing. Fixes dependency publishes (e.g. Bio @datagrok/chem) on CI runners where the image exists in the registry but not locally.
35
43
 
36
44
  ## 6.4.1 (2026-06-18)
37
45
 
38
- * Fixed `grok` failing with `Cannot find module './commands/build'` ??? the `.npmignore` `build.js` rule was unanchored and excluded the compiled `bin/commands/build.js` from the published package; anchored it to `/build.js`.
46
+ * Fixed `grok` failing with `Cannot find module './commands/build'` the `.npmignore` `build.js` rule was unanchored and excluded the compiled `bin/commands/build.js` from the published package; anchored it to `/build.js`.
39
47
  * Pinned `ignore-walk` to ^6.0.5 so the package still supports Node 18 (9.x requires Node 22+).
40
48
 
41
49
  ## 6.4.0 (2026-06-18)
@@ -47,46 +55,46 @@
47
55
 
48
56
  ## 6.3.3 (2026-06-16)
49
57
 
50
- * Fixed Celery Docker image generation ??? the image wasn't built locally on publish.
58
+ * Fixed Celery Docker image generation the image wasn't built locally on publish.
51
59
 
52
60
  ## 6.3.2 (2026-06-15)
53
61
 
54
- * `func-gen` webpack plugin ??? generated RichFunctionView model inputs now use the script-form names (argument bounds/step `_t0`/`_t1`/`_h`, loop count `_count`) instead of the deprefixed forms, so the run, fitting, and sensitivity-analysis paths share one set of input names with diff-grok's pipeline. Fixes `Inconsistent inputs: "_t0" is missing` when starting fitting/SA from a Rich Function View.
55
- * `func-gen` webpack plugin ??? the generated RichFunctionView model output annotation appends the `DiffStudio Facet` viewer (last), alongside Grid and Line chart.
56
- * `grok stresstest` ??? run the ApiTests Node test runner directly via tsx (`src/package-test-node.ts`) instead of a compiled `dist-node` bundle; dropped the obsolete `npm run build-node` step and the `tsconfig-paths-bootstrap.js` / `dist-node` invocation.
62
+ * `func-gen` webpack plugin generated RichFunctionView model inputs now use the script-form names (argument bounds/step `_t0`/`_t1`/`_h`, loop count `_count`) instead of the deprefixed forms, so the run, fitting, and sensitivity-analysis paths share one set of input names with diff-grok's pipeline. Fixes `Inconsistent inputs: "_t0" is missing` when starting fitting/SA from a Rich Function View.
63
+ * `func-gen` webpack plugin the generated RichFunctionView model output annotation appends the `DiffStudio Facet` viewer (last), alongside Grid and Line chart.
64
+ * `grok stresstest` run the ApiTests Node test runner directly via tsx (`src/package-test-node.ts`) instead of a compiled `dist-node` bundle; dropped the obsolete `npm run build-node` step and the `tsconfig-paths-bootstrap.js` / `dist-node` invocation.
57
65
 
58
66
  ## 6.3.0 (2026-06-12)
59
67
 
60
- * `func-gen` webpack plugin ??? generates RichFunctionView model wrappers from Diff Studio `#meta.role: model` `.ivp` files. Inputs/output are derived from the parsed IVP, `#meta.icon` becomes the model icon, and `#meta.inputs` lookups are emitted as real `propagateChoice: all` inputs (rendered natively by the Rich Function View, unlike `meta.inputs`). Ships a prebuilt, tree-shaken CJS bundle of diff-grok's IVP parser (`plugins/ivp-parser.bundle.cjs`); regenerate with `npm run update:ivp-parser`.
68
+ * `func-gen` webpack plugin generates RichFunctionView model wrappers from Diff Studio `#meta.role: model` `.ivp` files. Inputs/output are derived from the parsed IVP, `#meta.icon` becomes the model icon, and `#meta.inputs` lookups are emitted as real `propagateChoice: all` inputs (rendered natively by the Rich Function View, unlike `meta.inputs`). Ships a prebuilt, tree-shaken CJS bundle of diff-grok's IVP parser (`plugins/ivp-parser.bundle.cjs`); regenerate with `npm run update:ivp-parser`.
61
69
 
62
70
  ## 6.2.6 (2026-05-26)
63
71
 
64
- * `grok s tables upload` ??? accepts `.d42` binary blobs in addition to `.csv`. Content-Type is auto-detected from the file extension (`application/octet-stream` for d42, `text/csv` otherwise); server content-negotiates and persists either form against the same `/public/v1/tables/{name}` endpoint.
72
+ * `grok s tables upload` accepts `.d42` binary blobs in addition to `.csv`. Content-Type is auto-detected from the file extension (`application/octet-stream` for d42, `text/csv` otherwise); server content-negotiates and persists either form against the same `/public/v1/tables/{name}` endpoint.
65
73
 
66
74
  ## 6.2.5 (2026-05-21)
67
75
 
68
- * `grok report read` ??? renamed `--extract-actions` to `--extract-client-log`; sidecar is now `<stem>_client_log.json`. The old flag is no longer accepted.
69
- * `grok report read` ??? legacy `actions` field in pre-consolidation report zips is folded into `clientLog` at read time (stderr warning emitted), so downstream consumers see one canonical field. Companion to the platform-side merge that drops `reports_data.actions` in favor of `client_log`.
76
+ * `grok report read` renamed `--extract-actions` to `--extract-client-log`; sidecar is now `<stem>_client_log.json`. The old flag is no longer accepted.
77
+ * `grok report read` legacy `actions` field in pre-consolidation report zips is folded into `clientLog` at read time (stderr warning emitted), so downstream consumers see one canonical field. Companion to the platform-side merge that drops `reports_data.actions` in favor of `client_log`.
70
78
 
71
79
  ## 6.2.4 (2026-05-13)
72
80
 
73
- * GROK-20097: package template ??? replaced deprecated `"moduleResolution": "node"` in `tsconfig.json` with `"bundler"`, suppressing the TypeScript warning for new packages.
81
+ * GROK-20097: package template replaced deprecated `"moduleResolution": "node"` in `tsconfig.json` with `"bundler"`, suppressing the TypeScript warning for new packages.
74
82
 
75
83
  ## 6.2.3 (2026-05-06)
76
84
 
77
- * `grok test` ??? added `--skip-puppeteer` flag (symmetric counterpart of `--skip-playwright`). Bypasses `loadPackages()` and the Puppeteer/`DG.Test` runner so Playwright-only test directories (e.g. `public/playwright-public`) can run end-to-end via `grok test --skip-puppeteer` without tripping the Dart/JS package loader.
85
+ * `grok test` added `--skip-puppeteer` flag (symmetric counterpart of `--skip-playwright`). Bypasses `loadPackages()` and the Puppeteer/`DG.Test` runner so Playwright-only test directories (e.g. `public/playwright-public`) can run end-to-end via `grok test --skip-puppeteer` without tripping the Dart/JS package loader.
78
86
 
79
87
  ## 6.2.2 (2026-05-05)
80
88
 
81
- * `grok test` ??? Playwright runner now writes `test-report-playwright.csv` next to the existing merged `test-report.csv`, so CI can ship Playwright rows to a dedicated Datlas reporting bucket without disturbing the legacy `package` flow.
89
+ * `grok test` Playwright runner now writes `test-report-playwright.csv` next to the existing merged `test-report.csv`, so CI can ship Playwright rows to a dedicated Datlas reporting bucket without disturbing the legacy `package` flow.
82
90
 
83
91
  ## 6.2.1 (2026-05-05)
84
92
 
85
- * Reports: `grok report attach <ticket> <file>` ??? upload a file as a JIRA issue attachment via REST v2 multipart POST.
93
+ * Reports: `grok report attach <ticket> <file>` upload a file as a JIRA issue attachment via REST v2 multipart POST.
86
94
 
87
95
  ## 6.2.0 (2026-05-04)
88
96
 
89
- * `grok test` ??? Playwright support: when a package's `package.json` declares `"playwrightTests": "<path>"`, `grok test` runs `npx playwright test` against that directory in addition to the existing Puppeteer pass and merges results into a single `test-report.csv`. Auth is unified with the Puppeteer pass (dev key from `~/.grok/config.yaml` ??? session token ??? cookie + `localStorage` injection ??? no login form). Optional `DATAGROK_DEV_KEY_2` env var enables a second-user identity for specs that need it (`DATAGROK_AUTH_TOKEN_2` exposed to specs). New `--skip-playwright` flag opts out of the Playwright pass for a single run.
97
+ * `grok test` Playwright support: when a package's `package.json` declares `"playwrightTests": "<path>"`, `grok test` runs `npx playwright test` against that directory in addition to the existing Puppeteer pass and merges results into a single `test-report.csv`. Auth is unified with the Puppeteer pass (dev key from `~/.grok/config.yaml` session token cookie + `localStorage` injection no login form). Optional `DATAGROK_DEV_KEY_2` env var enables a second-user identity for specs that need it (`DATAGROK_AUTH_TOKEN_2` exposed to specs). New `--skip-playwright` flag opts out of the Playwright pass for a single run.
90
98
 
91
99
  ## 6.1.14 (2026-05-01)
92
100
 
@@ -98,17 +106,17 @@
98
106
 
99
107
  ## 6.1.11 (2026-04-27)
100
108
 
101
- * `grok report read <path | instance number>` ??? normalize a report zip/json into one JSON object on stdout (envelope unwrap, `_meta.json` merge, optional `--extract-screenshot` / `--extract-d42` / `--extract-actions`)
102
- * `grok report fetch` ??? single round-trip via the new `/reports/by-number/<number>/zip` endpoint, with fallback to the legacy search-then-download path on 404
109
+ * `grok report read <path | instance number>` normalize a report zip/json into one JSON object on stdout (envelope unwrap, `_meta.json` merge, optional `--extract-screenshot` / `--extract-d42` / `--extract-actions`)
110
+ * `grok report fetch` single round-trip via the new `/reports/by-number/<number>/zip` endpoint, with fallback to the legacy search-then-download path on 404
103
111
 
104
112
  ## 6.1.10 (2026-04-17)
105
113
 
106
- * `grok s connections save` ??? create/update a connection from a JSON file (`--save-credentials` optional)
107
- * `grok s connections test` ??? test connectivity by JSON body or by existing id/name
108
- * `grok s users save` ??? create/update a user from a JSON file
109
- * `grok s groups save` ??? create/update a group from a JSON file (`--save-relations` optional)
110
- * `grok s shares add` ??? share an entity with one or more groups (`--access View|Edit`)
111
- * `grok s shares list` ??? list who an entity is shared with
114
+ * `grok s connections save` create/update a connection from a JSON file (`--save-credentials` optional)
115
+ * `grok s connections test` test connectivity by JSON body or by existing id/name
116
+ * `grok s users save` create/update a user from a JSON file
117
+ * `grok s groups save` create/update a group from a JSON file (`--save-relations` optional)
118
+ * `grok s shares add` share an entity with one or more groups (`--access View|Edit`)
119
+ * `grok s shares list` list who an entity is shared with
112
120
  * Tools: Normalize package name to lowercase in grok create, preserve original as friendlyName
113
121
 
114
122
  ## 5.1.3 (2026-02-03)
@@ -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 &nbsp; / &amp; / &lt; / &gt;', () => {
53
+ (0, _vitest.expect)((0, _report.markdownToJiraWiki)('a&nbsp;b')).toBe('a b');
54
+ (0, _vitest.expect)((0, _report.markdownToJiraWiki)('a&amp;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"]}`);
@@ -289,22 +289,18 @@ async function test(args) {
289
289
  }
290
290
  }
291
291
  if (nodeRes != null) {
292
- // Merge the Node pass into the browser result (same CSV columns/order).
293
- if (!res.csv || res.csv.trim().split('\n').length < 2) {
294
- res.csv = nodeRes.csv;
295
- res.passedAmount += nodeRes.passedAmount;
296
- res.failedAmount += nodeRes.failedAmount;
297
- res.skippedAmount += nodeRes.skippedAmount;
298
- res.failed = res.failed || nodeRes.failed;
299
- res.verbosePassed = (res.verbosePassed || '') + nodeRes.verbosePassed;
300
- res.verboseFailed = (res.verboseFailed || '') + nodeRes.verboseFailed;
301
- res.verboseSkipped = (res.verboseSkipped || '') + nodeRes.verboseSkipped;
302
- res.modernOutput = res.modernOutput || nodeRes.modernOutput;
303
- } else if (nodeRes.csv && nodeRes.csv.trim().split('\n').length >= 2) {
304
- res = await (0, _testUtils.mergeBrowsersResults)([res, nodeRes]);
305
- } else {
306
- res.failed = res.failed || nodeRes.failed;
307
- }
292
+ // Merge the Node pass into the browser result. CSVs merge by column NAME —
293
+ // the two passes emit different column orders, and a line-wise concat would
294
+ // misalign rows (string values in integer columns break the report upload).
295
+ res.csv = testUtils.mergeCsvByHeader(res.csv, nodeRes.csv);
296
+ res.passedAmount += nodeRes.passedAmount;
297
+ res.failedAmount += nodeRes.failedAmount;
298
+ res.skippedAmount += nodeRes.skippedAmount;
299
+ res.failed = res.failed || nodeRes.failed;
300
+ res.verbosePassed = (res.verbosePassed || '') + nodeRes.verbosePassed;
301
+ res.verboseFailed = (res.verboseFailed || '') + nodeRes.verboseFailed;
302
+ res.verboseSkipped = (res.verboseSkipped || '') + nodeRes.verboseSkipped;
303
+ res.modernOutput = res.modernOutput || nodeRes.modernOutput;
308
304
  }
309
305
  if (!args['skip-playwright'] && !args['node-only']) {
310
306
  const ptDir = playwrightRunner.hasPlaywrightTests(curDir);
@@ -20,6 +20,7 @@ exports.loadPackage = loadPackage;
20
20
  exports.loadPackages = loadPackages;
21
21
  exports.loadTestsList = loadTestsList;
22
22
  exports.mergeBrowsersResults = mergeBrowsersResults;
23
+ exports.mergeCsvByHeader = mergeCsvByHeader;
23
24
  exports.printBrowsersResult = printBrowsersResult;
24
25
  exports.runBrowser = runBrowser;
25
26
  exports.runWithTimeout = runWithTimeout;
@@ -946,6 +947,29 @@ class TestContext {
946
947
  }
947
948
  }
948
949
  exports.TestContext = TestContext;
950
+ /**
951
+ * Merges two test-result CSVs by column NAME (union of headers), unlike
952
+ * mergeBrowsersResults' line concatenation which assumes identical column order.
953
+ * Used to merge the Node-pass report into the browser report.
954
+ */
955
+ function mergeCsvByHeader(a, b) {
956
+ const hasRows = csv => csv && csv.trim().split('\n').length >= 2;
957
+ if (!hasRows(a)) return hasRows(b) ? b : a || b || '';
958
+ if (!hasRows(b)) return a;
959
+ const pa = _papaparse.default.parse(a.trim(), {
960
+ header: true,
961
+ skipEmptyLines: true
962
+ });
963
+ const pb = _papaparse.default.parse(b.trim(), {
964
+ header: true,
965
+ skipEmptyLines: true
966
+ });
967
+ const columns = [...(pa.meta.fields ?? [])];
968
+ for (const f of pb.meta.fields ?? []) if (!columns.includes(f)) columns.push(f);
969
+ return _papaparse.default.unparse([...pa.data, ...pb.data], {
970
+ columns
971
+ });
972
+ }
949
973
  function addColumnToCsv(csv, columnName, defaultValue) {
950
974
  const results = _papaparse.default.parse(csv, {
951
975
  header: true,
@@ -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.0",
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": {