datagrok-tools 6.4.8 → 6.5.1

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,8 +1,17 @@
1
1
  # Datagrok-tools changelog
2
2
 
3
+ ## 6.5.1 (2026-07-22)
4
+
5
+ * `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`.
6
+
7
+ ## 6.5.0 (WIP)
8
+
9
+ * `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.
10
+
3
11
  ## 6.4.8 (2026-07-22)
4
12
 
5
13
  * 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.
14
+ * `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.
6
15
 
7
16
  ## 6.4.7 (2026-07-22)
8
17
 
@@ -11,7 +20,6 @@
11
20
  ## 6.4.6 (2026-07-22)
12
21
 
13
22
  * `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.
14
- * `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.
15
23
 
16
24
  ## 6.4.5 (2026-06-23)
17
25
 
package/README.md CHANGED
@@ -135,6 +135,8 @@ For more information on configuring connections, refer to the [Connections](http
135
135
  | report | Sends a report on test results |
136
136
  | skip-build | Skips package build step |
137
137
  | skip-publish | Skips package publish step |
138
+ | skip-node | Skips the Node (browserless) pass; runs all tests in the browser |
139
+ | node-only | Runs only tests annotated `{node: true}` headless under Node, without a browser |
138
140
  | verbose | Prints detailed information about passed and skipped tests in the console |
139
141
  | platform | Runs only platform tests (applicable for ApiTests package only) |
140
142
  | core | Runs package & core tests & autotests |
@@ -207,6 +207,8 @@ Options:
207
207
  --skip-publish Skip the package publication step
208
208
  --skip-puppeteer Skip the Puppeteer/DG.Test pass; only run Playwright (for playwright-only test directories)
209
209
  --skip-playwright Skip the Playwright pass; only run Puppeteer/DG.Test
210
+ --skip-node Skip the Node (browserless) pass; run all tests in the browser
211
+ --node-only Run only tests annotated {node: true} headless under Node, no browser
210
212
  --link Link the package to local utils
211
213
  --record Records the test execution process in mp4 format
212
214
  --platform Runs only platform tests (applicable for ApiTests package only)
@@ -20,6 +20,7 @@ var Papa = _interopRequireWildcard(require("papaparse"));
20
20
  var _testUtils = _interopRequireWildcard(require("../utils/test-utils"));
21
21
  var testUtils = _testUtils;
22
22
  var playwrightRunner = _interopRequireWildcard(require("../utils/playwright-runner"));
23
+ var _nodeTestRunner = require("../utils/node-test-runner");
23
24
  function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
24
25
  /* eslint-disable max-len */
25
26
 
@@ -29,7 +30,7 @@ const execFileAsync = (0, _util.promisify)(_child_process.execFile);
29
30
  // are discarded (they live in the page until a pass completes). CI runners on
30
31
  // loaded agents can exceed the 1h default — override via env.
31
32
  const testInvocationTimeout = parseInt(process.env['GROK_TEST_INVOCATION_TIMEOUT_MS'] ?? '', 10) || 3600000;
32
- const availableCommandOptions = ['host', 'package', 'csv', 'gui', 'catchUnhandled', 'platform', 'core', 'report', 'skip-build', 'skip-publish', 'path', 'record', 'verbose', 'benchmark', 'category', 'test', 'stress-test', 'link', 'tag', 'ci-cd', 'debug', 'no-retry', 'dartium', 'f', 'params', 'logfailed', 'skip-playwright', 'skip-puppeteer'];
33
+ const availableCommandOptions = ['host', 'package', 'csv', 'gui', 'catchUnhandled', 'platform', 'core', 'report', 'skip-build', 'skip-publish', 'path', 'record', 'verbose', 'benchmark', 'category', 'test', 'stress-test', 'link', 'tag', 'ci-cd', 'debug', 'no-retry', 'dartium', 'f', 'params', 'logfailed', 'skip-playwright', 'skip-puppeteer', 'skip-node', 'node-only'];
33
34
  const curDir = process.cwd();
34
35
 
35
36
  /** Expands camelCase to space-separated lowercase: "dataManipulation" → "data manipulation" */
@@ -228,12 +229,34 @@ async function test(args) {
228
229
  }
229
230
  }
230
231
  process.env.TARGET_PACKAGE = packageName;
232
+
233
+ // Node (browserless) pass: runs tests annotated {node: true} headless under the
234
+ // js-api Node runtime; the browser pass then excludes them. Skipped for --gui/--debug
235
+ // so every test stays visible in the browser there.
236
+ let nodeRes = undefined;
237
+ if (!args['skip-node'] && !args['skip-puppeteer'] && !args.gui && !args.debug && !args.package) {
238
+ nodeRes = await (0, _nodeTestRunner.runNodeTests)({
239
+ packageDir: curDir,
240
+ packageName: packageName,
241
+ host: args.host ?? '',
242
+ category: args.category,
243
+ test: args.test,
244
+ stressTest: args['stress-test'],
245
+ benchmark: args.benchmark,
246
+ verbose: args.verbose
247
+ });
248
+ }
249
+ if (args['node-only'] && nodeRes == null) {
250
+ color.error('--node-only: the Node test pass is not available for this package.');
251
+ process.exit(1);
252
+ }
231
253
  let res;
232
- if (args['skip-puppeteer']) {
254
+ if (args['skip-puppeteer'] || args['node-only'] || nodeRes != null && nodeRes.browserTestsRemaining === 0) {
233
255
  // Playwright-only mode: skip the Puppeteer browser launch + DG.Test runner.
234
256
  // Used by Playwright-only test directories (e.g. public/playwright-public)
235
257
  // that have a `playwrightTests` field in package.json but no Dart/JS package
236
- // tests on the server.
258
+ // tests on the server. Also taken when the Node pass covered every matched test.
259
+ if (nodeRes != null && nodeRes.browserTestsRemaining === 0 && !args['node-only'] && !args['skip-puppeteer']) color.info('All matched tests ran in the Node pass; skipping the browser run.');
237
260
  res = {
238
261
  failed: false,
239
262
  verbosePassed: '',
@@ -246,7 +269,7 @@ async function test(args) {
246
269
  };
247
270
  } else {
248
271
  try {
249
- res = await runTesting(args);
272
+ res = await runTesting(args, nodeRes != null);
250
273
  } catch (e) {
251
274
  // Don't let Puppeteer-side failures (login error, browser crash) skip the
252
275
  // Playwright pass — the two suites have independent auth and runtime paths,
@@ -265,7 +288,21 @@ async function test(args) {
265
288
  };
266
289
  }
267
290
  }
268
- if (!args['skip-playwright']) {
291
+ if (nodeRes != null) {
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;
304
+ }
305
+ if (!args['skip-playwright'] && !args['node-only']) {
269
306
  const ptDir = playwrightRunner.hasPlaywrightTests(curDir);
270
307
  if (ptDir) {
271
308
  const ptRes = await playwrightRunner.runPlaywrightTests(curDir, ptDir, args, args.host ?? '');
@@ -312,7 +349,7 @@ const retriedTests = new Set();
312
349
  let totalRetries = 0;
313
350
  const MAX_RETRIES_PER_SESSION = 10;
314
351
  let retryEnabled = true;
315
- async function runTesting(args) {
352
+ async function runTesting(args, excludeNodeTests = false) {
316
353
  retryEnabled = args['retry'] ?? true;
317
354
  if (args.test || args.category) retryEnabled = false;
318
355
  let organized = {
@@ -320,6 +357,7 @@ async function runTesting(args) {
320
357
  params: {
321
358
  category: args.category ?? '',
322
359
  test: args.test ?? '',
360
+ excludeNodeTests: excludeNodeTests || undefined,
323
361
  options: {
324
362
  catchUnhandled: args.catchUnhandled,
325
363
  report: args.report
@@ -956,6 +994,8 @@ function buildTestArgs(args) {
956
994
  if (args['stress-test']) parts.push('--stress-test');
957
995
  if (args['ci-cd']) parts.push('--ci-cd');
958
996
  if (args['no-retry']) parts.push('--no-retry');
997
+ if (args['skip-node']) parts.push('--skip-node');
998
+ if (args['node-only']) parts.push('--node-only');
959
999
  return parts;
960
1000
  }
961
1001
  function parseTestOutput(stdout) {
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+
3
+ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
4
+ Object.defineProperty(exports, "__esModule", {
5
+ value: true
6
+ });
7
+ exports.runNodeTests = runNodeTests;
8
+ var _child_process = require("child_process");
9
+ var _fs = _interopRequireDefault(require("fs"));
10
+ var _os = _interopRequireDefault(require("os"));
11
+ var _path = _interopRequireDefault(require("path"));
12
+ var _readline = _interopRequireDefault(require("readline"));
13
+ var color = _interopRequireWildcard(require("./color-utils"));
14
+ var _testUtils = require("./test-utils");
15
+ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
16
+ /* eslint-disable max-len */
17
+ /**
18
+ * CLI side of the `grok test` Node (browserless) pass: spawns node-test-worker.js
19
+ * with cwd = the package directory, streams its progress output through the shared
20
+ * TestProgressReporter, and returns a ResultObject-compatible report.
21
+ *
22
+ * Returns undefined when the package doesn't support the Node pass (no testNode export,
23
+ * no datagrok-api Node runtime, not published) or the worker fails — the caller then
24
+ * runs everything in the browser as before.
25
+ */
26
+
27
+ const workerTimeout = 3600000;
28
+ async function runNodeTests(opts) {
29
+ // Cheap opt-in gate: don't pay the worker spawn + runtime init for the vast
30
+ // majority of packages that don't export testNode() from package-test.ts.
31
+ try {
32
+ const testSrc = ['ts', 'js'].map(ext => _path.default.join(opts.packageDir, 'src', `package-test.${ext}`)).find(p => _fs.default.existsSync(p));
33
+ if (testSrc != null && !_fs.default.readFileSync(testSrc, 'utf8').includes('testNode')) return undefined;
34
+ } catch {}
35
+ let url;
36
+ let key;
37
+ try {
38
+ ({
39
+ url,
40
+ key
41
+ } = (0, _testUtils.getDevKey)(opts.host));
42
+ } catch (e) {
43
+ color.warn(`Node test pass skipped: ${e?.message ?? e}`);
44
+ return undefined;
45
+ }
46
+ const token = await (0, _testUtils.getToken)(url, key);
47
+ const workerPath = _path.default.join(__dirname, 'node-test-worker.js');
48
+ const outPath = _path.default.join(_os.default.tmpdir(), `grok-node-test-${Date.now()}-${process.pid}.json`);
49
+ const args = [workerPath, `--out=${outPath}`];
50
+ if (opts.category) args.push(`--category=${opts.category}`);
51
+ if (opts.test) args.push(`--test=${opts.test}`);
52
+ if (opts.stressTest) args.push('--stress-test');
53
+ if (opts.benchmark) args.push('--benchmark');
54
+ if (opts.verbose) args.push('--verbose');
55
+ color.info('Running Node (browserless) test pass...');
56
+ const reporter = new _testUtils.TestProgressReporter(opts.verbose ?? false);
57
+ const stderrTail = [];
58
+ const exitCode = await new Promise(resolve => {
59
+ const child = (0, _child_process.spawn)(process.execPath, args, {
60
+ cwd: opts.packageDir,
61
+ env: {
62
+ ...process.env,
63
+ DG_API_URL: url,
64
+ DG_API_TOKEN: token,
65
+ TARGET_PACKAGE: opts.packageName
66
+ },
67
+ stdio: ['ignore', 'pipe', 'pipe']
68
+ });
69
+ const killTimer = setTimeout(() => {
70
+ color.error(`Node test pass timed out after ${workerTimeout / 1000}s. Killing worker.`);
71
+ child.kill();
72
+ }, workerTimeout);
73
+ const rlOut = _readline.default.createInterface({
74
+ input: child.stdout
75
+ });
76
+ rlOut.on('line', line => {
77
+ if (line.startsWith('Package testing: ')) reporter.onLine(line);else if (line.startsWith('Node pass unavailable: ')) color.info(line);else if (opts.verbose) console.log(line);
78
+ });
79
+ const rlErr = _readline.default.createInterface({
80
+ input: child.stderr
81
+ });
82
+ rlErr.on('line', line => {
83
+ stderrTail.push(line);
84
+ if (stderrTail.length > 50) stderrTail.shift();
85
+ if (opts.verbose) console.error(line);
86
+ });
87
+ child.on('close', code => {
88
+ clearTimeout(killTimer);
89
+ rlOut.close();
90
+ rlErr.close();
91
+ resolve(code ?? 2);
92
+ });
93
+ child.on('error', e => {
94
+ clearTimeout(killTimer);
95
+ color.warn(`Failed to spawn Node test worker: ${e.message}`);
96
+ resolve(2);
97
+ });
98
+ });
99
+ reporter.finish();
100
+ if (exitCode === 3) {
101
+ color.info('Node test pass is not supported here; running all tests in the browser.');
102
+ return undefined;
103
+ }
104
+ if (exitCode !== 0) {
105
+ if (!opts.verbose && stderrTail.length > 0) console.error(stderrTail.join('\n'));
106
+ color.warn(`Node test pass failed (exit code ${exitCode}); running all tests in the browser.`);
107
+ return undefined;
108
+ }
109
+ try {
110
+ const report = JSON.parse(_fs.default.readFileSync(outPath, 'utf8'));
111
+ _fs.default.unlinkSync(outPath);
112
+ color.info(`Node pass: ${report.passedAmount} passed, ${report.failedAmount} failed, ${report.skippedAmount} skipped; ` + `${report.browserTestsRemaining} test(s) left for the browser.`);
113
+ return {
114
+ ...report,
115
+ modernOutput: true
116
+ };
117
+ } catch (e) {
118
+ color.warn(`Could not read Node test report: ${e?.message ?? e}; running all tests in the browser.`);
119
+ return undefined;
120
+ }
121
+ }
@@ -0,0 +1,210 @@
1
+ "use strict";
2
+
3
+ var vm = _interopRequireWildcard(require("vm"));
4
+ var fs = _interopRequireWildcard(require("fs"));
5
+ var path = _interopRequireWildcard(require("path"));
6
+ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
7
+ /* eslint-disable max-len */
8
+ /**
9
+ * Standalone worker for the `grok test` Node (browserless) pass.
10
+ *
11
+ * Spawned by node-test-runner.ts with cwd = the package directory:
12
+ * 1. loads the js-api Node runtime (`datagrok-api/datagrok`) from the package's own node_modules,
13
+ * 2. resolves the published package version the platform would serve and evaluates its
14
+ * `dist/package-test.js` bundle (webpack var-library `<name>_test`),
15
+ * 3. runs init-tagged functions of the test bundle, then calls its exported `testNode()`
16
+ * which executes only tests marked `{node: true}`,
17
+ * 4. counts the tests the browser pass still has to run, and writes a JSON report to --out.
18
+ *
19
+ * Exit codes: 0 = pass executed (test failures are conveyed in the report),
20
+ * 3 = Node pass unsupported for this package (caller falls back to the browser for everything),
21
+ * anything else = infrastructure error.
22
+ */
23
+
24
+ const UNSUPPORTED_EXIT_CODE = 3;
25
+ const RESULT_COLUMNS = ['date', 'category', 'name', 'success', 'result', 'ms', 'skipped', 'logs', 'owner', 'package', 'widgetsDifference', 'flaking'];
26
+ function getArg(name) {
27
+ const prefix = `--${name}=`;
28
+ const arg = process.argv.find(a => a.startsWith(prefix));
29
+ return arg ? arg.slice(prefix.length) : undefined;
30
+ }
31
+ function hasFlag(name) {
32
+ return process.argv.includes(`--${name}`);
33
+ }
34
+ function unsupported(reason) {
35
+ console.log(`Node pass unavailable: ${reason}`);
36
+ process.exit(UNSUPPORTED_EXIT_CODE);
37
+ }
38
+
39
+ // A crashing async callback (e.g. a socket handler inside the Dart runtime) must not
40
+ // kill the whole pass — log it and let the per-test timeout fail the affected test.
41
+ process.on('uncaughtException', err => {
42
+ console.error('Uncaught exception:', err?.stack ?? err);
43
+ });
44
+ process.on('unhandledRejection', reason => {
45
+ console.error('Unhandled rejection:', reason);
46
+ });
47
+ async function main() {
48
+ const apiUrl = process.env.DG_API_URL;
49
+ const token = process.env.DG_API_TOKEN;
50
+ const packageName = process.env.TARGET_PACKAGE;
51
+ const outPath = getArg('out');
52
+ const categoryArg = getArg('category');
53
+ const testArg = getArg('test');
54
+ const stressTest = hasFlag('stress-test');
55
+ const benchmark = hasFlag('benchmark');
56
+ const verbose = hasFlag('verbose');
57
+ let dgEntry;
58
+ try {
59
+ dgEntry = require.resolve('datagrok-api/datagrok', {
60
+ paths: [process.cwd()]
61
+ });
62
+ } catch {
63
+ unsupported('the installed datagrok-api has no Node runtime (datagrok-api/datagrok)');
64
+ }
65
+ const dgApi = require(dgEntry);
66
+ if (typeof dgApi.startDatagrok !== 'function') unsupported('the installed datagrok-api has no startDatagrok()');
67
+ await dgApi.startDatagrok({
68
+ apiUrl,
69
+ apiToken: token
70
+ });
71
+ const g = globalThis;
72
+ const grok = g.grok;
73
+ const DG = g.DG;
74
+
75
+ // Package bundles map webpack externals to browser globals (rxjs, rxjs.operators,
76
+ // dayjs, utc, wu, $). The Node runtime guarantees DG/grok/ui; provide the rest
77
+ // from datagrok-api's own dependencies so bundle evaluation doesn't throw.
78
+ const req = id => require(require.resolve(id, {
79
+ paths: [path.dirname(dgEntry), process.cwd()]
80
+ }));
81
+ const ensureGlobal = (name, resolve) => {
82
+ if (g[name] == null) try {
83
+ g[name] = resolve();
84
+ } catch {}
85
+ };
86
+ ensureGlobal('rxjs', () => req('rxjs'));
87
+ if (g.rxjs != null && g.rxjs.operators == null) try {
88
+ g.rxjs = Object.assign({}, g.rxjs, {
89
+ operators: req('rxjs/operators')
90
+ });
91
+ } catch {}
92
+ ensureGlobal('dayjs', () => req('dayjs'));
93
+ ensureGlobal('utc', () => req('dayjs/plugin/utc'));
94
+ ensureGlobal('wu', () => req('wu'));
95
+ ensureGlobal('$', () => req('cash-dom'));
96
+
97
+ // Resolve the published version the platform would serve: the caller's debug version
98
+ // if one exists, else the current one (mirrors js-api's Node loadPackage()).
99
+ const headers = {
100
+ Authorization: token
101
+ };
102
+ const listResp = await fetch(`${apiUrl}/packages?text=${encodeURIComponent(packageName)}`, {
103
+ headers
104
+ });
105
+ if (!listResp.ok) throw new Error(`Failed to list packages: HTTP ${listResp.status}`);
106
+ const packages = await listResp.json();
107
+ const pkgInfo = packages.find(p => p.name?.toLowerCase() === packageName.toLowerCase());
108
+ if (!pkgInfo) unsupported(`package "${packageName}" is not published on ${apiUrl}`);
109
+ const versions = pkgInfo.publishedVersions ?? [];
110
+ const login = (await grok.dapi.users.current()).login;
111
+ const cur = versions.find(v => v.debug && v.version === login) ?? versions.find(v => v.isCurrent) ?? versions.find(v => v.isLatest) ?? versions[0];
112
+ if (!cur) unsupported(`package "${packageName}" has no published versions`);
113
+ const webRoot = `${apiUrl}/packages/published/files/${pkgInfo.name}/${cur.version}/${cur.buildHash}/${cur.buildNumber}/`;
114
+ const jsUrl = `${webRoot}dist/package-test.js`;
115
+ const codeResp = await fetch(jsUrl, {
116
+ headers
117
+ });
118
+ if (!codeResp.ok) unsupported(`no test bundle at ${jsUrl} (HTTP ${codeResp.status})`);
119
+ vm.runInThisContext(await codeResp.text(), {
120
+ filename: jsUrl
121
+ });
122
+ const moduleName = `${packageName.toLowerCase()}_test`;
123
+ const testModule = g[moduleName] ?? g[Object.keys(g).find(k => k.endsWith('_test') && g[k]?.tests) ?? ''];
124
+ if (!testModule?.tests) unsupported(`test bundle did not define a "${moduleName}" module`);
125
+ if (typeof testModule.testNode !== 'function') unsupported('package-test.ts does not export testNode()');
126
+ if (testModule._package != null) {
127
+ testModule._package.webRoot = webRoot;
128
+ testModule._package.name = pkgInfo.name;
129
+ testModule._package.version = cur.version;
130
+ }
131
+
132
+ // The package must carry the resolved published-version id: functions are attached to
133
+ // package versions, and runTests' initAutoTests filters `package.id = <id>` to register
134
+ // the annotation-driven auto tests. The family-level entity id matches nothing.
135
+ const pkg = (await grok.dapi.packages.find(cur.id)) ?? (await grok.dapi.packages.filter(`shortName = "${pkgInfo.name}"`).first());
136
+ if (!pkg) unsupported(`package entity "${pkgInfo.name}" not found`);
137
+ try {
138
+ if (pkg.name !== pkgInfo.name) pkg.name = pkgInfo.name;
139
+ } catch {}
140
+ if (benchmark) DG.Test.isInBenchmark = true;
141
+ const results = await testModule.testNode(pkg, {
142
+ category: categoryArg,
143
+ test: testArg,
144
+ stressTest,
145
+ verbose
146
+ });
147
+
148
+ // Count what the browser pass still has to run. testNode() ran initAutoTests(), so the
149
+ // registry now also holds the annotation-driven auto/demo/detector tests.
150
+ let browserTestsRemaining = 0;
151
+ for (const [catName, cat] of Object.entries(testModule.tests)) {
152
+ if (categoryArg != null && !catName.toLowerCase().startsWith(categoryArg.toLowerCase().trim())) continue;
153
+ for (const t of cat.tests ?? []) {
154
+ if (testArg != null && t.name.toLowerCase() !== testArg.toLowerCase()) continue;
155
+ if (benchmark && !t.options?.benchmark) continue;
156
+ if (stressTest && !t.options?.stressTest) continue;
157
+ if (!(t.options?.node ?? cat.node ?? false)) browserTestsRemaining++;
158
+ }
159
+ }
160
+
161
+ // Shape the report like the browser pass does — same CSV columns in the same order,
162
+ // so the two passes merge row-wise.
163
+ const Papa = require('papaparse');
164
+ const rows = results.map(r => {
165
+ const row = {};
166
+ for (const c of RESULT_COLUMNS) row[c] = r[c] ?? (c === 'widgetsDifference' ? 0 : '');
167
+ return row;
168
+ });
169
+ let failed = false;
170
+ let passedAmount = 0;
171
+ let skippedAmount = 0;
172
+ let failedAmount = 0;
173
+ let verbosePassed = '';
174
+ let verboseSkipped = '';
175
+ let verboseFailed = '';
176
+ for (const r of results) {
177
+ const line = `${r.category}: ${r.name} (${r.ms} ms) : ${r.result}\n`;
178
+ if (r.skipped) {
179
+ skippedAmount++;
180
+ verboseSkipped += line;
181
+ } else if (r.success) {
182
+ passedAmount++;
183
+ verbosePassed += line;
184
+ } else {
185
+ failedAmount++;
186
+ verboseFailed += line;
187
+ failed = true;
188
+ }
189
+ }
190
+ const report = {
191
+ failed,
192
+ verbosePassed,
193
+ verboseSkipped,
194
+ verboseFailed,
195
+ passedAmount,
196
+ skippedAmount,
197
+ failedAmount,
198
+ csv: rows.length ? Papa.unparse(rows, {
199
+ columns: RESULT_COLUMNS
200
+ }) : '',
201
+ browserTestsRemaining,
202
+ nodeTestsRun: results.length
203
+ };
204
+ fs.writeFileSync(outPath, JSON.stringify(report), 'utf8');
205
+ process.exit(0);
206
+ }
207
+ main().catch(e => {
208
+ console.error(e?.stack ?? e?.message ?? String(e));
209
+ process.exit(2);
210
+ });
@@ -4,7 +4,7 @@ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefau
4
4
  Object.defineProperty(exports, "__esModule", {
5
5
  value: true
6
6
  });
7
- exports.TestContext = void 0;
7
+ exports.TestProgressReporter = exports.TestContext = void 0;
8
8
  exports.addColumnToCsv = addColumnToCsv;
9
9
  exports.addLogsToFile = addLogsToFile;
10
10
  exports.defaultLaunchParameters = void 0;
@@ -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;
@@ -363,6 +364,132 @@ async function loadTestsList(packages, core = false, record = false) {
363
364
  function addLogsToFile(filePath, stringToSave) {
364
365
  _fs.default.appendFileSync(filePath, `${stringToSave}`);
365
366
  }
367
+
368
+ /**
369
+ * Parses the `Package testing: ...` progress lines emitted by the
370
+ * @datagrok-libraries/test runner and prints live per-category summaries.
371
+ * Used for both the Puppeteer console stream and the Node-pass worker stdout.
372
+ */
373
+ class TestProgressReporter {
374
+ modernOutput = false;
375
+ categoryResults = new Map();
376
+ currentCategory = null;
377
+ pendingFailures = [];
378
+ pendingSkipped = [];
379
+ pendingBeforeFailure = null;
380
+ pendingAfterFailure = null;
381
+ constructor(verbose = false) {
382
+ this.verbose = verbose;
383
+ }
384
+ printCategorySummary(category) {
385
+ const results = this.categoryResults.get(category);
386
+ if (!results) return;
387
+ const passedCount = results.passed;
388
+ const skippedSuffix = results.skipped > 0 ? `, \x1b[33m${results.skipped} skipped\x1b[0m` : '';
389
+ if (results.failed > 0 || this.pendingBeforeFailure || this.pendingAfterFailure) {
390
+ console.log(`\x1b[31m❌ ${category}\x1b[31m (\x1b[32m${passedCount} passed${skippedSuffix}\x1b[31m)\x1b[0m`);
391
+ if (this.pendingBeforeFailure) {
392
+ console.log(` \x1b[31m❌ before\x1b[0m`);
393
+ if (this.pendingBeforeFailure.error) console.log(` \x1b[31m${this.pendingBeforeFailure.error}\x1b[0m`);
394
+ console.log(` \x1b[33mTo run this category separately use --category "${category}"\x1b[0m`);
395
+ }
396
+ if (this.pendingAfterFailure) {
397
+ console.log(` \x1b[31m❌ after\x1b[0m`);
398
+ if (this.pendingAfterFailure.error) console.log(` \x1b[31m${this.pendingAfterFailure.error}\x1b[0m`);
399
+ console.log(` \x1b[33mTo run this category separately use --category "${category}"\x1b[0m`);
400
+ }
401
+ for (const skippedName of this.pendingSkipped) console.log(` \x1b[33m⊘ ${skippedName}\x1b[0m`);
402
+ for (const failure of this.pendingFailures) {
403
+ console.log(` \x1b[31m❌ ${failure.testName}\x1b[0m`);
404
+ if (failure.error) console.log(` \x1b[31m${failure.error}\x1b[0m`);
405
+ console.log(` \x1b[33mTo run this test separately use --category "${category}" --test "${failure.testName}"\x1b[0m`);
406
+ }
407
+ } else {
408
+ console.log(`\x1b[32m✅ ${category} (${passedCount} passed${skippedSuffix}\x1b[32m)\x1b[0m`);
409
+ for (const skippedName of this.pendingSkipped) console.log(` \x1b[33m⊘ ${skippedName}\x1b[0m`);
410
+ }
411
+ this.pendingFailures = [];
412
+ this.pendingSkipped = [];
413
+ this.pendingBeforeFailure = null;
414
+ this.pendingAfterFailure = null;
415
+ }
416
+ onLine(text) {
417
+ if (!text.startsWith('Package testing: ')) return;
418
+ this.modernOutput = true;
419
+ // Extract tokens in {{...}} format
420
+ const tokens = [];
421
+ const tokenRegex = /\{\{([^}]+)\}\}/g;
422
+ let match;
423
+ while ((match = tokenRegex.exec(text)) !== null) tokens.push(match[1]);
424
+
425
+ // Category start: "Package testing: Started {{Category}}" or "Package testing: Started {{Category}} skipped {{N}}"
426
+ if (text.includes('Started') && (tokens.length === 1 || tokens.length === 2 && text.includes('skipped'))) {
427
+ if (this.currentCategory && this.categoryResults.has(this.currentCategory)) this.printCategorySummary(this.currentCategory);
428
+ this.currentCategory = tokens[0];
429
+ const skippedCount = tokens.length === 2 ? parseInt(tokens[1]) || 0 : 0;
430
+ this.categoryResults.set(this.currentCategory, {
431
+ passed: 0,
432
+ failed: 0,
433
+ skipped: skippedCount
434
+ });
435
+ this.pendingFailures = [];
436
+ this.pendingSkipped = [];
437
+ this.pendingBeforeFailure = null;
438
+ this.pendingAfterFailure = null;
439
+ } else if (text.includes('Skipped') && tokens.length === 2 && !text.includes('benchmark')) {
440
+ // Individual skipped test: "Package testing: Skipped {{Category}} {{TestName}}"
441
+ if (tokens[0] === this.currentCategory) this.pendingSkipped.push(tokens[1]);
442
+ } else if (text.includes('Started') && tokens.length === 2) {
443
+ // Test start: "Package testing: Started {{Category}} {{TestName}}"
444
+ process.stdout.write(`${tokens[0]}: ${tokens[1]}...`);
445
+ } else if (text.includes('Finished') && tokens.length === 3) {
446
+ // Test finish: "Package testing: Finished {{Category}} {{TestName}} with {{success/error}} for X ms"
447
+ const results = this.categoryResults.get(tokens[0]);
448
+ if (!this.verbose) process.stdout.write('\r\x1b[K');
449
+ if (tokens[2] === 'success') {
450
+ if (results) results.passed++;
451
+ } else {
452
+ if (results) results.failed++;
453
+ this.pendingFailures.push({
454
+ testName: tokens[1],
455
+ error: null
456
+ });
457
+ }
458
+ } else if (text.includes('Result for') && tokens.length === 2) {
459
+ // Error result: "Package testing: Result for {{Category}} {{TestName}}: error message"
460
+ const errorMsg = text.split(': ').slice(-1)[0];
461
+ if (this.pendingFailures.length > 0) this.pendingFailures[this.pendingFailures.length - 1].error = errorMsg;
462
+ } else if (text.includes('Category before()') && text.includes('failed') && tokens.length >= 1) {
463
+ // Category before() failed: "Package testing: Category before() {{Category}} failed"
464
+ process.stdout.write('\r\x1b[K');
465
+ this.pendingBeforeFailure = {
466
+ error: null
467
+ };
468
+ } else if (text.includes('Result for') && text.includes('before:') && tokens.length >= 1) {
469
+ // Before error result: "Package testing: Result for {{Category}} before: error message"
470
+ const errorMsg = text.split('before: ').slice(-1)[0];
471
+ if (this.pendingBeforeFailure) this.pendingBeforeFailure.error = errorMsg;
472
+ } else if (text.includes('Category after()') && text.includes('failed') && tokens.length >= 1) {
473
+ // Category after() failed: "Package testing: Category after() {{Category}} failed"
474
+ process.stdout.write('\r\x1b[K');
475
+ this.pendingAfterFailure = {
476
+ error: null
477
+ };
478
+ } else if (text.includes('Result for') && text.includes('after:') && tokens.length >= 1) {
479
+ // After error result: "Package testing: Result for {{Category}} after: error message"
480
+ const errorMsg = text.split('after: ').slice(-1)[0];
481
+ if (this.pendingAfterFailure) this.pendingAfterFailure.error = errorMsg;
482
+ } else if (text.includes('Unhandled Exception')) {
483
+ process.stdout.write('\r\x1b[K\n');
484
+ const errorMsg = text.replace('Package testing: Unhandled Exception: ', '');
485
+ if (errorMsg && errorMsg !== 'null') console.log(`\x1b[31m❌ Unhandled Exception: ${errorMsg}\x1b[0m`);
486
+ }
487
+ }
488
+ finish() {
489
+ if (this.currentCategory && this.categoryResults.has(this.currentCategory)) this.printCategorySummary(this.currentCategory);
490
+ }
491
+ }
492
+ exports.TestProgressReporter = TestProgressReporter;
366
493
  function printBrowsersResult(browserResult, verbose = false) {
367
494
  // Skip detailed summary if modernOutput was used (already printed per-category)
368
495
  if (!browserResult.modernOutput) {
@@ -414,8 +541,9 @@ async function runTests(testParams) {
414
541
  let countSkipped = 0;
415
542
  let countFailed = 0;
416
543
  try {
417
- // Check if retry is supported by looking for skipToCategory parameter
544
+ // Check feature support by looking at the test function's declared inputs
418
545
  let retrySupported = false;
546
+ let excludeNodeSupported = false;
419
547
  try {
420
548
  const funcs = window.DG.Func.find({
421
549
  package: testParams.package,
@@ -424,6 +552,7 @@ async function runTests(testParams) {
424
552
  if (funcs && funcs.length > 0) {
425
553
  const testFunc = funcs[0];
426
554
  retrySupported = testFunc.inputs?.some(input => input.name === 'skipToCategory') === true;
555
+ excludeNodeSupported = testFunc.inputs?.some(input => input.name === 'excludeNodeTests') === true;
427
556
  }
428
557
  } catch (e) {
429
558
  retrySupported = false;
@@ -431,6 +560,8 @@ async function runTests(testParams) {
431
560
  const testCallParams = {};
432
561
  if (testParams.params?.category) testCallParams.category = testParams.params.category;
433
562
  if (testParams.params?.test) testCallParams.test = testParams.params.test;
563
+ // Skip node-marked tests in the browser only when the Node pass already ran them
564
+ if (testParams.params?.excludeNodeTests && excludeNodeSupported) testCallParams.excludeNodeTests = true;
434
565
  // Only pass retry-related params if supported
435
566
  if (retrySupported) {
436
567
  if (testParams.params?.skipToCategory) testCallParams.skipToCategory = testParams.params.skipToCategory;
@@ -692,54 +823,8 @@ async function runBrowser(testExecutionData, browserOptions, browsersId, testInv
692
823
  });
693
824
  }
694
825
 
695
- // State tracking for test output formatting
696
- const categoryResults = new Map();
697
- let currentCategory = null;
698
- let currentTestName = null;
699
- // Store failed tests with their errors to print after category header
700
- let pendingFailures = [];
701
- let pendingSkipped = [];
702
- let pendingBeforeFailure = null;
703
- let pendingAfterFailure = null;
704
- let modernOutput = false;
705
- const printCategorySummary = category => {
706
- const results = categoryResults.get(category);
707
- if (!results) return;
708
- const formattedCategory = category;
709
- const passedCount = results.passed;
710
- const skippedSuffix = results.skipped > 0 ? `, \x1b[33m${results.skipped} skipped\x1b[0m` : '';
711
- if (results.failed > 0 || pendingBeforeFailure || pendingAfterFailure) {
712
- console.log(`\x1b[31m❌ ${formattedCategory}\x1b[31m (\x1b[32m${passedCount} passed${skippedSuffix}\x1b[31m)\x1b[0m`);
713
- // Print before() failure first
714
- if (pendingBeforeFailure) {
715
- console.log(` \x1b[31m❌ before\x1b[0m`);
716
- if (pendingBeforeFailure.error) console.log(` \x1b[31m${pendingBeforeFailure.error}\x1b[0m`);
717
- console.log(` \x1b[33mTo run this category separately use --category "${category}"\x1b[0m`);
718
- }
719
- // Print after() failure
720
- if (pendingAfterFailure) {
721
- console.log(` \x1b[31m❌ after\x1b[0m`);
722
- if (pendingAfterFailure.error) console.log(` \x1b[31m${pendingAfterFailure.error}\x1b[0m`);
723
- console.log(` \x1b[33mTo run this category separately use --category "${category}"\x1b[0m`);
724
- }
725
- // Print skipped tests
726
- for (const skippedName of pendingSkipped) console.log(` \x1b[33m⊘ ${skippedName}\x1b[0m`);
727
- // Print test failures
728
- for (const failure of pendingFailures) {
729
- console.log(` \x1b[31m❌ ${failure.testName}\x1b[0m`);
730
- if (failure.error) console.log(` \x1b[31m${failure.error}\x1b[0m`);
731
- console.log(` \x1b[33mTo run this test separately use --category "${category}" --test "${failure.testName}"\x1b[0m`);
732
- }
733
- } else {
734
- console.log(`\x1b[32m✅ ${formattedCategory} (${passedCount} passed${skippedSuffix}\x1b[32m)\x1b[0m`);
735
- // Print skipped tests
736
- for (const skippedName of pendingSkipped) console.log(` \x1b[33m⊘ ${skippedName}\x1b[0m`);
737
- }
738
- pendingFailures = [];
739
- pendingSkipped = [];
740
- pendingBeforeFailure = null;
741
- pendingAfterFailure = null;
742
- };
826
+ // Live test-output formatting (shared with the Node pass)
827
+ const reporter = new TestProgressReporter(browserOptions.verbose ?? false);
743
828
 
744
829
  // Print all console messages when verbose mode is enabled
745
830
  if (browserOptions.verbose) {
@@ -756,95 +841,7 @@ async function runBrowser(testExecutionData, browserOptions, browsersId, testInv
756
841
 
757
842
  // Subscribe to page console events for modern output formatting
758
843
  // On retry, old listeners were removed so we need to re-attach
759
- page.on('console', msg => {
760
- const text = msg.text();
761
- if (!text.startsWith('Package testing: ')) return;
762
- modernOutput = true;
763
- // Extract tokens in {{...}} format
764
- const tokens = [];
765
- const tokenRegex = /\{\{([^}]+)\}\}/g;
766
- let match;
767
- while ((match = tokenRegex.exec(text)) !== null) tokens.push(match[1]);
768
-
769
- // Category start: "Package testing: Started {{Category}}" or "Package testing: Started {{Category}} skipped {{N}}"
770
- if (text.includes('Started') && (tokens.length === 1 || tokens.length === 2 && text.includes('skipped'))) {
771
- // Print summary of previous category if exists
772
- if (currentCategory && categoryResults.has(currentCategory)) printCategorySummary(currentCategory);
773
- currentCategory = tokens[0];
774
- const skippedCount = tokens.length === 2 ? parseInt(tokens[1]) || 0 : 0;
775
- categoryResults.set(currentCategory, {
776
- passed: 0,
777
- failed: 0,
778
- skipped: skippedCount
779
- });
780
- pendingFailures = [];
781
- pendingSkipped = [];
782
- pendingBeforeFailure = null;
783
- pendingAfterFailure = null;
784
- } else if (text.includes('Skipped') && tokens.length === 2 && !text.includes('benchmark')) {
785
- // Individual skipped test: "Package testing: Skipped {{Category}} {{TestName}}"
786
- // Only collect if the test belongs to the current active category
787
- if (tokens[0] === currentCategory) pendingSkipped.push(tokens[1]);
788
- } else if (text.includes('Started') && tokens.length === 2) {
789
- // Test start: "Package testing: Started {{Category}} {{TestName}}"
790
- const category = tokens[0];
791
- currentTestName = tokens[1];
792
- process.stdout.write(`${category}: ${currentTestName}...`);
793
- } else if (text.includes('Finished') && tokens.length === 3) {
794
- // Test finish: "Package testing: Finished {{Category}} {{TestName}} with {{success/error}} for X ms"
795
- const category = tokens[0];
796
- const testName = tokens[1];
797
- const status = tokens[2];
798
- const results = categoryResults.get(category);
799
-
800
- // Clear the current test line
801
- if (!browserOptions.verbose) process.stdout.write('\r\x1b[K');
802
- if (status === 'success') {
803
- if (results) results.passed++;
804
- } else {
805
- if (results) results.failed++;
806
- pendingFailures.push({
807
- testName,
808
- error: null
809
- });
810
- }
811
- currentTestName = null;
812
- } else if (text.includes('Result for') && tokens.length === 2) {
813
- // Error result: "Package testing: Result for {{Category}} {{TestName}}: error message"
814
- const errorMsg = text.split(': ').slice(-1)[0];
815
- // Attach error to the last pending failure
816
- if (pendingFailures.length > 0) pendingFailures[pendingFailures.length - 1].error = errorMsg;
817
- } else if (text.includes('Category before()') && text.includes('failed') && tokens.length >= 1) {
818
- // Category before() failed: "Package testing: Category before() {{Category}} failed"
819
- process.stdout.write('\r\x1b[K');
820
- pendingBeforeFailure = {
821
- error: null
822
- };
823
- } else if (text.includes('Result for') && text.includes('before:') && tokens.length >= 1) {
824
- // Before error result: "Package testing: Result for {{Category}} before: error message"
825
- const errorMsg = text.split('before: ').slice(-1)[0];
826
- if (pendingBeforeFailure) pendingBeforeFailure.error = errorMsg;
827
- } else if (text.includes('Category after()') && text.includes('failed') && tokens.length >= 1) {
828
- // Category after() failed: "Package testing: Category after() {{Category}} failed"
829
- process.stdout.write('\r\x1b[K');
830
- pendingAfterFailure = {
831
- error: null
832
- };
833
- } else if (text.includes('Result for') && text.includes('after:') && tokens.length >= 1) {
834
- // After error result: "Package testing: Result for {{Category}} after: error message"
835
- const errorMsg = text.split('after: ').slice(-1)[0];
836
- if (pendingAfterFailure) pendingAfterFailure.error = errorMsg;
837
- } else if (text.includes('Unhandled Exception')) {
838
- // Unhandled exception: "Package testing: Unhandled Exception: ..."
839
- // Clear any pending test line and move to new line
840
- process.stdout.write('\r\x1b[K\n');
841
- const errorMsg = text.replace('Package testing: Unhandled Exception: ', '');
842
- if (errorMsg && errorMsg !== 'null') console.log(`\x1b[31m❌ Unhandled Exception: ${errorMsg}\x1b[0m`);
843
- }
844
- });
845
- const printFinalCategorySummary = () => {
846
- if (currentCategory && categoryResults.has(currentCategory)) printCategorySummary(currentCategory);
847
- };
844
+ page.on('console', msg => reporter.onLine(msg.text()));
848
845
  const testingResults = await page.evaluate((testData, options) => {
849
846
  if (options.benchmark) window.DG.Test.isInBenchmark = true;
850
847
  if (options.reproduce) window.DG.Test.isReproducing = true;
@@ -889,9 +886,9 @@ async function runBrowser(testExecutionData, browserOptions, browsersId, testInv
889
886
  }, testsToRun, browserOptions);
890
887
 
891
888
  // Print the final category summary
892
- printFinalCategorySummary();
889
+ reporter.finish();
893
890
  if (browserOptions.record && !existingBrowserSession) await recorder?.stop();
894
- if (modernOutput) {
891
+ if (reporter.modernOutput) {
895
892
  testingResults.verbosePassed = '';
896
893
  testingResults.verboseSkipped = '';
897
894
  testingResults.verboseFailed = '';
@@ -908,7 +905,7 @@ async function runBrowser(testExecutionData, browserOptions, browsersId, testInv
908
905
  page,
909
906
  webUrl
910
907
  } : undefined,
911
- modernOutput
908
+ modernOutput: reporter.modernOutput
912
909
  };
913
910
  }, testInvocationTimeout);
914
911
  }
@@ -950,6 +947,29 @@ class TestContext {
950
947
  }
951
948
  }
952
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
+ }
953
973
  function addColumnToCsv(csv, columnName, defaultValue) {
954
974
  const results = _papaparse.default.parse(csv, {
955
975
  header: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "datagrok-tools",
3
- "version": "6.4.8",
3
+ "version": "6.5.1",
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": {