datagrok-tools 6.1.13 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Datagrok-tools changelog
|
|
2
2
|
|
|
3
|
+
## 6.2.0 (2026-05-04)
|
|
4
|
+
|
|
5
|
+
* `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 `--no-playwright` flag opts out of the Playwright pass for a single run.
|
|
6
|
+
|
|
7
|
+
## 6.1.14 (2026-05-01)
|
|
8
|
+
|
|
9
|
+
* Reports: `grok report comment` now converts Markdown body to JIRA wiki markup before POSTing, fixing rendered headings/list/HTML-entity mismatches in JIRA UI.
|
|
10
|
+
|
|
3
11
|
## 6.1.13 (2026-04-30)
|
|
4
12
|
|
|
5
13
|
* Reports: `grok report ticket` now uses direct JIRA REST honoring `$JIRA_PROJECT`, replacing the Datlas-mediated path that hardcoded GROK.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _vitest = require("vitest");
|
|
4
|
+
var _report = require("../commands/report");
|
|
5
|
+
(0, _vitest.describe)('markdownToJiraWiki — basic rules', () => {
|
|
6
|
+
(0, _vitest.it)('converts H1', () => {
|
|
7
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('# Title')).toBe('h1. Title');
|
|
8
|
+
});
|
|
9
|
+
(0, _vitest.it)('converts H2 / H3 / H6', () => {
|
|
10
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('## Sub')).toBe('h2. Sub');
|
|
11
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('### Sub-sub')).toBe('h3. Sub-sub');
|
|
12
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('###### Deep')).toBe('h6. Deep');
|
|
13
|
+
});
|
|
14
|
+
(0, _vitest.it)('converts bold', () => {
|
|
15
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('hello **world** foo')).toBe('hello *world* foo');
|
|
16
|
+
});
|
|
17
|
+
(0, _vitest.it)('converts italic with single asterisks', () => {
|
|
18
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('hello *world* foo')).toBe('hello _world_ foo');
|
|
19
|
+
});
|
|
20
|
+
(0, _vitest.it)('converts strikethrough', () => {
|
|
21
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('~~gone~~')).toBe('-gone-');
|
|
22
|
+
});
|
|
23
|
+
(0, _vitest.it)('converts links', () => {
|
|
24
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('see [docs](https://x.example/y)')).toBe('see [docs|https://x.example/y]');
|
|
25
|
+
});
|
|
26
|
+
(0, _vitest.it)('converts blockquote', () => {
|
|
27
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('> quoted line')).toBe('bq. quoted line');
|
|
28
|
+
});
|
|
29
|
+
(0, _vitest.it)('converts unordered list', () => {
|
|
30
|
+
const md = '- one\n- two\n- three';
|
|
31
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)(md)).toBe('* one\n* two\n* three');
|
|
32
|
+
});
|
|
33
|
+
(0, _vitest.it)('converts one level of nested unordered list', () => {
|
|
34
|
+
const md = '- top\n - sub\n- back';
|
|
35
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)(md)).toBe('* top\n** sub\n* back');
|
|
36
|
+
});
|
|
37
|
+
(0, _vitest.it)('converts ordered list', () => {
|
|
38
|
+
const md = '1. one\n2. two\n3. three';
|
|
39
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)(md)).toBe('# one\n# two\n# three');
|
|
40
|
+
});
|
|
41
|
+
(0, _vitest.it)('converts inline code', () => {
|
|
42
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('use `foo()` here')).toBe('use {{foo()}} here');
|
|
43
|
+
});
|
|
44
|
+
(0, _vitest.it)('converts plain code fence to noformat', () => {
|
|
45
|
+
const md = '```\nraw code\nmore\n```';
|
|
46
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)(md)).toBe('{noformat}\nraw code\nmore\n{noformat}');
|
|
47
|
+
});
|
|
48
|
+
(0, _vitest.it)('converts code fence with language tag to {code:lang}', () => {
|
|
49
|
+
const md = '```js\nconst x = 1;\n```';
|
|
50
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)(md)).toBe('{code:js}\nconst x = 1;\n{code}');
|
|
51
|
+
});
|
|
52
|
+
(0, _vitest.it)('converts HTML entities / & / < / >', () => {
|
|
53
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('a b')).toBe('a b');
|
|
54
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('a&b')).toBe('a&b');
|
|
55
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('a<b>c')).toBe('a<b>c');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
(0, _vitest.describe)('markdownToJiraWiki — content-protection inside code fences', () => {
|
|
59
|
+
(0, _vitest.it)('does not transform `{{plates}}` inside a fenced block (run-#4 false-positive case)', () => {
|
|
60
|
+
const md = ['Before', '```', 'context: {{plates}} should stay literal', '# not a heading', '- not a list', '```', 'After'].join('\n');
|
|
61
|
+
const out = (0, _report.markdownToJiraWiki)(md);
|
|
62
|
+
(0, _vitest.expect)(out).toContain('{noformat}\ncontext: {{plates}} should stay literal');
|
|
63
|
+
(0, _vitest.expect)(out).toContain('# not a heading');
|
|
64
|
+
(0, _vitest.expect)(out).toContain('- not a list');
|
|
65
|
+
(0, _vitest.expect)(out).not.toContain('h1.');
|
|
66
|
+
});
|
|
67
|
+
(0, _vitest.it)('does not transform headings/links/lists inside an inline code span', () => {
|
|
68
|
+
const md = 'use `# not a heading` and `[ref](u)` here';
|
|
69
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)(md)).toBe('use {{# not a heading}} and {{[ref](u)}} here');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
(0, _vitest.describe)('markdownToJiraWiki — edge cases', () => {
|
|
73
|
+
(0, _vitest.it)('handles bold containing italic: **bold *inside* bold**', () => {
|
|
74
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('**bold *inside* bold**')).toBe('*bold _inside_ bold*');
|
|
75
|
+
});
|
|
76
|
+
(0, _vitest.it)('preserves bold and italic on the same line', () => {
|
|
77
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('**a** and *b*')).toBe('*a* and _b_');
|
|
78
|
+
});
|
|
79
|
+
(0, _vitest.it)('handles a heading whose text contains backticks', () => {
|
|
80
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('## use `foo()` for x')).toBe('h2. use {{foo()}} for x');
|
|
81
|
+
});
|
|
82
|
+
(0, _vitest.it)('passes plain text through unchanged', () => {
|
|
83
|
+
const plain = 'Just a normal sentence with no markdown.';
|
|
84
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)(plain)).toBe(plain);
|
|
85
|
+
});
|
|
86
|
+
(0, _vitest.it)('returns empty string unchanged', () => {
|
|
87
|
+
(0, _vitest.expect)((0, _report.markdownToJiraWiki)('')).toBe('');
|
|
88
|
+
});
|
|
89
|
+
(0, _vitest.it)('handles a multi-block document end-to-end', () => {
|
|
90
|
+
const md = ['# Handoff', '', 'Some **bold** intro and a [link](https://x.example).', '', '## Findings', '', '- first', '- second', '', '> note: this matters', '', '```js', 'const x = 1;', '```', '', 'Done here.'].join('\n');
|
|
91
|
+
const out = (0, _report.markdownToJiraWiki)(md);
|
|
92
|
+
(0, _vitest.expect)(out).toContain('h1. Handoff');
|
|
93
|
+
(0, _vitest.expect)(out).toContain('h2. Findings');
|
|
94
|
+
(0, _vitest.expect)(out).toContain('Some *bold* intro and a [link|https://x.example].');
|
|
95
|
+
(0, _vitest.expect)(out).toContain('* first\n* second');
|
|
96
|
+
(0, _vitest.expect)(out).toContain('bq. note: this matters');
|
|
97
|
+
(0, _vitest.expect)(out).toContain('{code:js}\nconst x = 1;\n{code}');
|
|
98
|
+
(0, _vitest.expect)(out).toContain('Done here.');
|
|
99
|
+
(0, _vitest.expect)(out).not.toContain(' ');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import {describe, it, expect} from 'vitest';
|
|
2
|
+
import {markdownToJiraWiki} from '../commands/report';
|
|
3
|
+
|
|
4
|
+
describe('markdownToJiraWiki — basic rules', () => {
|
|
5
|
+
it('converts H1', () => {
|
|
6
|
+
expect(markdownToJiraWiki('# Title')).toBe('h1. Title');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('converts H2 / H3 / H6', () => {
|
|
10
|
+
expect(markdownToJiraWiki('## Sub')).toBe('h2. Sub');
|
|
11
|
+
expect(markdownToJiraWiki('### Sub-sub')).toBe('h3. Sub-sub');
|
|
12
|
+
expect(markdownToJiraWiki('###### Deep')).toBe('h6. Deep');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('converts bold', () => {
|
|
16
|
+
expect(markdownToJiraWiki('hello **world** foo')).toBe('hello *world* foo');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('converts italic with single asterisks', () => {
|
|
20
|
+
expect(markdownToJiraWiki('hello *world* foo')).toBe('hello _world_ foo');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('converts strikethrough', () => {
|
|
24
|
+
expect(markdownToJiraWiki('~~gone~~')).toBe('-gone-');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('converts links', () => {
|
|
28
|
+
expect(markdownToJiraWiki('see [docs](https://x.example/y)'))
|
|
29
|
+
.toBe('see [docs|https://x.example/y]');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('converts blockquote', () => {
|
|
33
|
+
expect(markdownToJiraWiki('> quoted line')).toBe('bq. quoted line');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('converts unordered list', () => {
|
|
37
|
+
const md = '- one\n- two\n- three';
|
|
38
|
+
expect(markdownToJiraWiki(md)).toBe('* one\n* two\n* three');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('converts one level of nested unordered list', () => {
|
|
42
|
+
const md = '- top\n - sub\n- back';
|
|
43
|
+
expect(markdownToJiraWiki(md)).toBe('* top\n** sub\n* back');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('converts ordered list', () => {
|
|
47
|
+
const md = '1. one\n2. two\n3. three';
|
|
48
|
+
expect(markdownToJiraWiki(md)).toBe('# one\n# two\n# three');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('converts inline code', () => {
|
|
52
|
+
expect(markdownToJiraWiki('use `foo()` here')).toBe('use {{foo()}} here');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('converts plain code fence to noformat', () => {
|
|
56
|
+
const md = '```\nraw code\nmore\n```';
|
|
57
|
+
expect(markdownToJiraWiki(md)).toBe('{noformat}\nraw code\nmore\n{noformat}');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('converts code fence with language tag to {code:lang}', () => {
|
|
61
|
+
const md = '```js\nconst x = 1;\n```';
|
|
62
|
+
expect(markdownToJiraWiki(md)).toBe('{code:js}\nconst x = 1;\n{code}');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('converts HTML entities / & / < / >', () => {
|
|
66
|
+
expect(markdownToJiraWiki('a b')).toBe('a b');
|
|
67
|
+
expect(markdownToJiraWiki('a&b')).toBe('a&b');
|
|
68
|
+
expect(markdownToJiraWiki('a<b>c')).toBe('a<b>c');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('markdownToJiraWiki — content-protection inside code fences', () => {
|
|
73
|
+
it('does not transform `{{plates}}` inside a fenced block (run-#4 false-positive case)', () => {
|
|
74
|
+
const md = [
|
|
75
|
+
'Before',
|
|
76
|
+
'```',
|
|
77
|
+
'context: {{plates}} should stay literal',
|
|
78
|
+
'# not a heading',
|
|
79
|
+
'- not a list',
|
|
80
|
+
'```',
|
|
81
|
+
'After',
|
|
82
|
+
].join('\n');
|
|
83
|
+
const out = markdownToJiraWiki(md);
|
|
84
|
+
expect(out).toContain('{noformat}\ncontext: {{plates}} should stay literal');
|
|
85
|
+
expect(out).toContain('# not a heading');
|
|
86
|
+
expect(out).toContain('- not a list');
|
|
87
|
+
expect(out).not.toContain('h1.');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('does not transform headings/links/lists inside an inline code span', () => {
|
|
91
|
+
const md = 'use `# not a heading` and `[ref](u)` here';
|
|
92
|
+
expect(markdownToJiraWiki(md))
|
|
93
|
+
.toBe('use {{# not a heading}} and {{[ref](u)}} here');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('markdownToJiraWiki — edge cases', () => {
|
|
98
|
+
it('handles bold containing italic: **bold *inside* bold**', () => {
|
|
99
|
+
expect(markdownToJiraWiki('**bold *inside* bold**'))
|
|
100
|
+
.toBe('*bold _inside_ bold*');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('preserves bold and italic on the same line', () => {
|
|
104
|
+
expect(markdownToJiraWiki('**a** and *b*')).toBe('*a* and _b_');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('handles a heading whose text contains backticks', () => {
|
|
108
|
+
expect(markdownToJiraWiki('## use `foo()` for x'))
|
|
109
|
+
.toBe('h2. use {{foo()}} for x');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('passes plain text through unchanged', () => {
|
|
113
|
+
const plain = 'Just a normal sentence with no markdown.';
|
|
114
|
+
expect(markdownToJiraWiki(plain)).toBe(plain);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns empty string unchanged', () => {
|
|
118
|
+
expect(markdownToJiraWiki('')).toBe('');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('handles a multi-block document end-to-end', () => {
|
|
122
|
+
const md = [
|
|
123
|
+
'# Handoff',
|
|
124
|
+
'',
|
|
125
|
+
'Some **bold** intro and a [link](https://x.example).',
|
|
126
|
+
'',
|
|
127
|
+
'## Findings',
|
|
128
|
+
'',
|
|
129
|
+
'- first',
|
|
130
|
+
'- second',
|
|
131
|
+
'',
|
|
132
|
+
'> note: this matters',
|
|
133
|
+
'',
|
|
134
|
+
'```js',
|
|
135
|
+
'const x = 1;',
|
|
136
|
+
'```',
|
|
137
|
+
'',
|
|
138
|
+
'Done here.',
|
|
139
|
+
].join('\n');
|
|
140
|
+
const out = markdownToJiraWiki(md);
|
|
141
|
+
expect(out).toContain('h1. Handoff');
|
|
142
|
+
expect(out).toContain('h2. Findings');
|
|
143
|
+
expect(out).toContain('Some *bold* intro and a [link|https://x.example].');
|
|
144
|
+
expect(out).toContain('* first\n* second');
|
|
145
|
+
expect(out).toContain('bq. note: this matters');
|
|
146
|
+
expect(out).toContain('{code:js}\nconst x = 1;\n{code}');
|
|
147
|
+
expect(out).toContain('Done here.');
|
|
148
|
+
expect(out).not.toContain(' ');
|
|
149
|
+
});
|
|
150
|
+
});
|
package/bin/commands/report.js
CHANGED
|
@@ -4,6 +4,7 @@ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefau
|
|
|
4
4
|
Object.defineProperty(exports, "__esModule", {
|
|
5
5
|
value: true
|
|
6
6
|
});
|
|
7
|
+
exports.markdownToJiraWiki = markdownToJiraWiki;
|
|
7
8
|
exports.report = report;
|
|
8
9
|
var _fs = _interopRequireDefault(require("fs"));
|
|
9
10
|
var _os = _interopRequireDefault(require("os"));
|
|
@@ -436,8 +437,10 @@ async function handleTicket(args) {
|
|
|
436
437
|
// the Datagrok org instance; override via --jira-url or $JIRA_URL.
|
|
437
438
|
//
|
|
438
439
|
// Why v2 and not v3: v3 requires comment bodies in ADF (Atlassian Document
|
|
439
|
-
// Format) JSON
|
|
440
|
-
//
|
|
440
|
+
// Format) JSON, which is much heavier to construct. v2 accepts a plain string
|
|
441
|
+
// body, which JIRA renders as wiki markup — NOT Markdown. So a `# heading`
|
|
442
|
+
// becomes a top-level ordered-list item, ` ` shows up literally, etc.
|
|
443
|
+
// `markdownToJiraWiki` below bridges the gap for Markdown-emitting callers.
|
|
441
444
|
|
|
442
445
|
function resolveJiraBase(args) {
|
|
443
446
|
const cli = args['jira-url'] || '';
|
|
@@ -450,6 +453,76 @@ function jiraAuthHeader() {
|
|
|
450
453
|
if (!user || !token) return null;
|
|
451
454
|
return 'Basic ' + Buffer.from(`${user}:${token}`).toString('base64');
|
|
452
455
|
}
|
|
456
|
+
|
|
457
|
+
// Convert a Markdown string to JIRA wiki markup so that REST v2 renders it the
|
|
458
|
+
// way the author intended. Handles the common cases that diverge between the
|
|
459
|
+
// two dialects; for anything not listed, the input is passed through unchanged.
|
|
460
|
+
//
|
|
461
|
+
// Deferred for v1 (left as-is, may render imperfectly):
|
|
462
|
+
// - tables (Markdown and JIRA wiki use very similar pipe syntax)
|
|
463
|
+
// - nested lists deeper than two levels
|
|
464
|
+
// - footnotes, definition lists, raw HTML
|
|
465
|
+
function markdownToJiraWiki(md) {
|
|
466
|
+
if (!md) return md;
|
|
467
|
+
|
|
468
|
+
// 1. Code fences first: extract their content into placeholders so that
|
|
469
|
+
// later rules cannot rewrite anything inside `{noformat}` / `{code}`.
|
|
470
|
+
const placeholders = [];
|
|
471
|
+
const protect = s => {
|
|
472
|
+
placeholders.push(s);
|
|
473
|
+
return `\x00P${placeholders.length - 1}\x00`;
|
|
474
|
+
};
|
|
475
|
+
let out = md;
|
|
476
|
+
out = out.replace(/```([A-Za-z0-9_+\-]*)\r?\n([\s\S]*?)```/g, (_m, lang, code) => {
|
|
477
|
+
const wiki = lang ? `{code:${lang}}\n${code}{code}` : `{noformat}\n${code}{noformat}`;
|
|
478
|
+
return protect(wiki);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// 2. Inline code spans (after fences, before anything else).
|
|
482
|
+
out = out.replace(/`([^`\n]+)`/g, (_m, code) => protect(`{{${code}}}`));
|
|
483
|
+
|
|
484
|
+
// 3. Headings — h6 → h1 so that the `###` prefix doesn't get matched as `##`.
|
|
485
|
+
// Must run before list rules (where `#` would otherwise be ambiguous).
|
|
486
|
+
out = out.replace(/^###### (.+)$/gm, 'h6. $1');
|
|
487
|
+
out = out.replace(/^##### (.+)$/gm, 'h5. $1');
|
|
488
|
+
out = out.replace(/^#### (.+)$/gm, 'h4. $1');
|
|
489
|
+
out = out.replace(/^### (.+)$/gm, 'h3. $1');
|
|
490
|
+
out = out.replace(/^## (.+)$/gm, 'h2. $1');
|
|
491
|
+
out = out.replace(/^# (.+)$/gm, 'h1. $1');
|
|
492
|
+
|
|
493
|
+
// 4. Bold then italic. We stash bold runs behind a sentinel first so that
|
|
494
|
+
// the inner italic pass can't mistake the leftover single `*` markers for
|
|
495
|
+
// italic (and vice-versa for `**bold *italic* bold**`).
|
|
496
|
+
out = out.replace(/\*\*([\s\S]+?)\*\*/g, '\x01$1\x01');
|
|
497
|
+
out = out.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '_$1_');
|
|
498
|
+
out = out.replace(/\x01/g, '*');
|
|
499
|
+
|
|
500
|
+
// 5. Strikethrough.
|
|
501
|
+
out = out.replace(/~~([^~\n]+)~~/g, '-$1-');
|
|
502
|
+
|
|
503
|
+
// 6. Links: [text](url) → [text|url].
|
|
504
|
+
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[$1|$2]');
|
|
505
|
+
|
|
506
|
+
// 7. Blockquote.
|
|
507
|
+
out = out.replace(/^> (.+)$/gm, 'bq. $1');
|
|
508
|
+
|
|
509
|
+
// 8. Unordered lists (one level of nesting). Two-space indent → second level.
|
|
510
|
+
out = out.replace(/^ - /gm, '** ');
|
|
511
|
+
out = out.replace(/^- /gm, '* ');
|
|
512
|
+
|
|
513
|
+
// 9. Ordered lists. After the heading pass so we don't clobber `# Title`.
|
|
514
|
+
out = out.replace(/^\d+\. /gm, '# ');
|
|
515
|
+
|
|
516
|
+
// 10. HTML entities that JIRA wiki shows literally.
|
|
517
|
+
out = out.replace(/ /g, ' ');
|
|
518
|
+
out = out.replace(/&/g, '&');
|
|
519
|
+
out = out.replace(/</g, '<');
|
|
520
|
+
out = out.replace(/>/g, '>');
|
|
521
|
+
|
|
522
|
+
// Restore protected fences / inline code last.
|
|
523
|
+
out = out.replace(/\x00P(\d+)\x00/g, (_m, idx) => placeholders[Number(idx)]);
|
|
524
|
+
return out;
|
|
525
|
+
}
|
|
453
526
|
async function handleComment(args) {
|
|
454
527
|
const ticket = args._[2];
|
|
455
528
|
if (!ticket) {
|
|
@@ -482,6 +555,10 @@ async function handleComment(args) {
|
|
|
482
555
|
}
|
|
483
556
|
const base = resolveJiraBase(args);
|
|
484
557
|
const url = `${base}/rest/api/2/issue/${encodeURIComponent(ticket)}/comment`;
|
|
558
|
+
// Callers (especially the dg-fix-reports M2 handoff) emit Markdown, but JIRA
|
|
559
|
+
// REST v2 renders the body as wiki markup. Convert before posting so headings,
|
|
560
|
+
// lists, and HTML entities don't render as garbage in the JIRA UI.
|
|
561
|
+
const wikiBody = markdownToJiraWiki(body);
|
|
485
562
|
try {
|
|
486
563
|
const resp = await fetch(url, {
|
|
487
564
|
method: 'POST',
|
|
@@ -491,7 +568,7 @@ async function handleComment(args) {
|
|
|
491
568
|
Accept: 'application/json'
|
|
492
569
|
},
|
|
493
570
|
body: JSON.stringify({
|
|
494
|
-
body
|
|
571
|
+
body: wikiBody
|
|
495
572
|
})
|
|
496
573
|
});
|
|
497
574
|
if (resp.status !== 200 && resp.status !== 201) {
|
package/bin/commands/test.js
CHANGED
|
@@ -19,13 +19,14 @@ var _build = require("./build");
|
|
|
19
19
|
var Papa = _interopRequireWildcard(require("papaparse"));
|
|
20
20
|
var _testUtils = _interopRequireWildcard(require("../utils/test-utils"));
|
|
21
21
|
var testUtils = _testUtils;
|
|
22
|
+
var playwrightRunner = _interopRequireWildcard(require("../utils/playwright-runner"));
|
|
22
23
|
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); }
|
|
23
24
|
/* eslint-disable max-len */
|
|
24
25
|
|
|
25
26
|
const execAsync = (0, _util.promisify)(_child_process.exec);
|
|
26
27
|
const execFileAsync = (0, _util.promisify)(_child_process.execFile);
|
|
27
28
|
const testInvocationTimeout = 3600000;
|
|
28
|
-
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'];
|
|
29
|
+
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', 'no-playwright'];
|
|
29
30
|
const curDir = process.cwd();
|
|
30
31
|
|
|
31
32
|
/** Expands camelCase to space-separated lowercase: "dataManipulation" → "data manipulation" */
|
|
@@ -224,7 +225,28 @@ async function test(args) {
|
|
|
224
225
|
}
|
|
225
226
|
}
|
|
226
227
|
process.env.TARGET_PACKAGE = packageName;
|
|
227
|
-
|
|
228
|
+
let res = await runTesting(args);
|
|
229
|
+
if (!args['no-playwright']) {
|
|
230
|
+
const ptDir = playwrightRunner.hasPlaywrightTests(curDir);
|
|
231
|
+
if (ptDir) {
|
|
232
|
+
const ptRes = await playwrightRunner.runPlaywrightTests(curDir, ptDir, args, args.host ?? '');
|
|
233
|
+
// mergeBrowsersResults assumes both inputs have a header row; an empty
|
|
234
|
+
// Puppeteer CSV (filter matched zero tests) breaks the merge. Take the
|
|
235
|
+
// Playwright CSV verbatim in that case, otherwise merge.
|
|
236
|
+
if (!res.csv || res.csv.trim().split('\n').length < 2) {
|
|
237
|
+
res.csv = ptRes.csv;
|
|
238
|
+
res.passedAmount += ptRes.passedAmount;
|
|
239
|
+
res.failedAmount += ptRes.failedAmount;
|
|
240
|
+
res.skippedAmount += ptRes.skippedAmount;
|
|
241
|
+
res.failed = res.failed || ptRes.failed;
|
|
242
|
+
res.verbosePassed = (res.verbosePassed || '') + ptRes.verbosePassed;
|
|
243
|
+
res.verboseFailed = (res.verboseFailed || '') + ptRes.verboseFailed;
|
|
244
|
+
res.verboseSkipped = (res.verboseSkipped || '') + ptRes.verboseSkipped;
|
|
245
|
+
} else if (ptRes.csv && ptRes.csv.trim().split('\n').length >= 2) {
|
|
246
|
+
res = await (0, _testUtils.mergeBrowsersResults)([res, ptRes]);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
228
250
|
if (args.csv) {
|
|
229
251
|
res.csv = (0, _testUtils.addColumnToCsv)(res.csv, 'stress_test', args['stress-test'] ?? false);
|
|
230
252
|
res.csv = (0, _testUtils.addColumnToCsv)(res.csv, 'benchmark', args.benchmark ?? false);
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
|
|
4
|
+
Object.defineProperty(exports, "__esModule", {
|
|
5
|
+
value: true
|
|
6
|
+
});
|
|
7
|
+
exports.hasPlaywrightTests = hasPlaywrightTests;
|
|
8
|
+
exports.runPlaywrightTests = runPlaywrightTests;
|
|
9
|
+
var _child_process = require("child_process");
|
|
10
|
+
var _fs = _interopRequireDefault(require("fs"));
|
|
11
|
+
var _path = _interopRequireDefault(require("path"));
|
|
12
|
+
var _papaparse = _interopRequireDefault(require("papaparse"));
|
|
13
|
+
var color = _interopRequireWildcard(require("./color-utils"));
|
|
14
|
+
var testUtils = _interopRequireWildcard(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
|
+
function hasPlaywrightTests(pkgDir) {
|
|
17
|
+
const pkgJsonPath = _path.default.join(pkgDir, 'package.json');
|
|
18
|
+
if (!_fs.default.existsSync(pkgJsonPath)) return null;
|
|
19
|
+
let pkgJson;
|
|
20
|
+
try {
|
|
21
|
+
pkgJson = JSON.parse(_fs.default.readFileSync(pkgJsonPath, 'utf-8'));
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const rel = pkgJson.playwrightTests;
|
|
26
|
+
if (typeof rel !== 'string' || rel.length === 0) return null;
|
|
27
|
+
const abs = _path.default.resolve(pkgDir, rel);
|
|
28
|
+
if (!_fs.default.existsSync(abs)) return null;
|
|
29
|
+
return abs;
|
|
30
|
+
}
|
|
31
|
+
function flattenSuites(suites, testDir, pkgName, owner, verbose, rows) {
|
|
32
|
+
if (!suites) return;
|
|
33
|
+
const isoDate = new Date().toISOString();
|
|
34
|
+
for (var suite of suites) {
|
|
35
|
+
if (suite.specs) {
|
|
36
|
+
for (var spec of suite.specs) {
|
|
37
|
+
const specFile = spec.file || suite.file || '';
|
|
38
|
+
const absSpec = _path.default.isAbsolute(specFile) ? specFile : _path.default.resolve(testDir, specFile);
|
|
39
|
+
const category = _path.default.relative(testDir, _path.default.dirname(absSpec)).replace(/\\/g, '/');
|
|
40
|
+
for (var t of spec.tests || []) {
|
|
41
|
+
const result = t.results && t.results[t.results.length - 1] || undefined;
|
|
42
|
+
if (!result) continue;
|
|
43
|
+
const status = result.status;
|
|
44
|
+
const skipped = status === 'skipped';
|
|
45
|
+
const success = status === 'passed';
|
|
46
|
+
var errMsg = '';
|
|
47
|
+
if (!success && !skipped && result.errors && result.errors.length > 0) errMsg = result.errors.map(e => e.message || e.stack || '').filter(s => s.length > 0).join('\n');
|
|
48
|
+
var logs = '';
|
|
49
|
+
if (verbose) {
|
|
50
|
+
const out = (result.stdout || []).map(s => s.text || '').join('');
|
|
51
|
+
const err = (result.stderr || []).map(s => s.text || '').join('');
|
|
52
|
+
logs = [out, err].filter(s => s.length > 0).join('\n');
|
|
53
|
+
}
|
|
54
|
+
rows.push({
|
|
55
|
+
date: isoDate,
|
|
56
|
+
category: category,
|
|
57
|
+
name: spec.title,
|
|
58
|
+
success: success,
|
|
59
|
+
result: errMsg,
|
|
60
|
+
ms: Math.round(result.duration || 0),
|
|
61
|
+
skipped: skipped,
|
|
62
|
+
logs: logs,
|
|
63
|
+
owner: owner,
|
|
64
|
+
package: pkgName,
|
|
65
|
+
widgetsDifference: '',
|
|
66
|
+
flaking: false
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (suite.suites) flattenSuites(suite.suites, testDir, pkgName, owner, verbose, rows);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function rowsToCsv(rows) {
|
|
75
|
+
const header = ['date', 'category', 'name', 'success', 'result', 'ms', 'skipped', 'logs', 'owner', 'package', 'widgetsDifference', 'flaking'];
|
|
76
|
+
return _papaparse.default.unparse({
|
|
77
|
+
fields: header,
|
|
78
|
+
data: rows.map(r => header.map(h => r[h]))
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async function runPlaywrightTests(pkgDir, testDir, args, hostKey) {
|
|
82
|
+
const empty = {
|
|
83
|
+
failed: false,
|
|
84
|
+
verbosePassed: '',
|
|
85
|
+
verboseSkipped: '',
|
|
86
|
+
verboseFailed: '',
|
|
87
|
+
passedAmount: 0,
|
|
88
|
+
skippedAmount: 0,
|
|
89
|
+
failedAmount: 0,
|
|
90
|
+
csv: ''
|
|
91
|
+
};
|
|
92
|
+
let url;
|
|
93
|
+
let key;
|
|
94
|
+
try {
|
|
95
|
+
({
|
|
96
|
+
url,
|
|
97
|
+
key
|
|
98
|
+
} = testUtils.getDevKey(hostKey));
|
|
99
|
+
} catch (e) {
|
|
100
|
+
color.error(`Playwright: cannot resolve host '${hostKey}': ${e.message || e}`);
|
|
101
|
+
return {
|
|
102
|
+
...empty,
|
|
103
|
+
failed: true,
|
|
104
|
+
failedAmount: 1,
|
|
105
|
+
verboseFailed: `Playwright: ${e.message || e}\n`
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
let token;
|
|
109
|
+
try {
|
|
110
|
+
token = await testUtils.getToken(url, key);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
color.error(`Playwright: cannot exchange dev key for token: ${e.message || e}`);
|
|
113
|
+
return {
|
|
114
|
+
...empty,
|
|
115
|
+
failed: true,
|
|
116
|
+
failedAmount: 1,
|
|
117
|
+
verboseFailed: `Playwright: ${e.message || e}\n`
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
let webUrl;
|
|
121
|
+
try {
|
|
122
|
+
webUrl = await testUtils.getWebUrl(url, token);
|
|
123
|
+
if (webUrl.endsWith('/')) webUrl = webUrl.slice(0, -1);
|
|
124
|
+
} catch {
|
|
125
|
+
webUrl = url.replace(/\/api\/?$/, '');
|
|
126
|
+
}
|
|
127
|
+
let token2 = '';
|
|
128
|
+
if (process.env.DATAGROK_DEV_KEY_2 && process.env.DATAGROK_DEV_KEY_2.length > 0) {
|
|
129
|
+
try {
|
|
130
|
+
token2 = await testUtils.getToken(url, process.env.DATAGROK_DEV_KEY_2);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
color.warn(`Playwright: DATAGROK_DEV_KEY_2 set but failed to exchange for token: ${e.message || e}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const configPath = _path.default.join(testDir, 'playwright.config.ts');
|
|
136
|
+
if (!_fs.default.existsSync(configPath)) {
|
|
137
|
+
color.error(`Playwright: ${configPath} not found.`);
|
|
138
|
+
return {
|
|
139
|
+
...empty,
|
|
140
|
+
failed: true,
|
|
141
|
+
failedAmount: 1,
|
|
142
|
+
verboseFailed: 'Playwright: missing playwright.config.ts\n'
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const reportFile = _path.default.join(pkgDir, 'test-playwright-report.json');
|
|
146
|
+
if (_fs.default.existsSync(reportFile)) _fs.default.unlinkSync(reportFile);
|
|
147
|
+
const cliArgs = ['--no-install', 'playwright', 'test', `--config=${configPath}`];
|
|
148
|
+
if (!args.gui) cliArgs.push(`--reporter=json`);else cliArgs.push('--headed');
|
|
149
|
+
if (args.test) cliArgs.push(`--grep=${args.test}`);
|
|
150
|
+
if (args['no-retry']) cliArgs.push('--retries=0');
|
|
151
|
+
let testDirFinal = testDir;
|
|
152
|
+
if (args.category) {
|
|
153
|
+
const candidate = _path.default.join(testDir, args.category);
|
|
154
|
+
if (_fs.default.existsSync(candidate)) testDirFinal = candidate;
|
|
155
|
+
}
|
|
156
|
+
if (testDirFinal !== testDir) cliArgs.push(testDirFinal);
|
|
157
|
+
const env = {
|
|
158
|
+
...process.env,
|
|
159
|
+
DATAGROK_URL: webUrl,
|
|
160
|
+
DATAGROK_AUTH_TOKEN: token,
|
|
161
|
+
PLAYWRIGHT_JSON_OUTPUT_NAME: reportFile
|
|
162
|
+
};
|
|
163
|
+
if (token2) env.DATAGROK_AUTH_TOKEN_2 = token2;
|
|
164
|
+
color.info(`Playwright: running ${_path.default.relative(pkgDir, testDir) || '.'} against ${webUrl}`);
|
|
165
|
+
const stdoutChunks = [];
|
|
166
|
+
const stderrChunks = [];
|
|
167
|
+
const exitCode = await new Promise(resolve => {
|
|
168
|
+
const isWin = process.platform === 'win32';
|
|
169
|
+
const child = (0, _child_process.spawn)(isWin ? 'npx.cmd' : 'npx', cliArgs, {
|
|
170
|
+
cwd: pkgDir,
|
|
171
|
+
env: env,
|
|
172
|
+
shell: isWin
|
|
173
|
+
});
|
|
174
|
+
child.stdout.on('data', d => {
|
|
175
|
+
stdoutChunks.push(d);
|
|
176
|
+
if (args.gui || args.verbose) process.stdout.write(d);
|
|
177
|
+
});
|
|
178
|
+
child.stderr.on('data', d => {
|
|
179
|
+
stderrChunks.push(d);
|
|
180
|
+
process.stderr.write(d);
|
|
181
|
+
});
|
|
182
|
+
child.on('error', e => {
|
|
183
|
+
color.error(`Playwright: failed to spawn npx: ${e.message}`);
|
|
184
|
+
resolve(1);
|
|
185
|
+
});
|
|
186
|
+
child.on('close', code => resolve(code ?? 1));
|
|
187
|
+
});
|
|
188
|
+
if (args.gui) {
|
|
189
|
+
return {
|
|
190
|
+
...empty,
|
|
191
|
+
failed: exitCode !== 0,
|
|
192
|
+
failedAmount: exitCode !== 0 ? 1 : 0,
|
|
193
|
+
passedAmount: exitCode === 0 ? 1 : 0,
|
|
194
|
+
verboseFailed: exitCode !== 0 ? 'Playwright (gui mode) exited non-zero\n' : ''
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
let report;
|
|
198
|
+
if (_fs.default.existsSync(reportFile)) {
|
|
199
|
+
try {
|
|
200
|
+
report = JSON.parse(_fs.default.readFileSync(reportFile, 'utf-8'));
|
|
201
|
+
} catch (e) {
|
|
202
|
+
color.warn(`Playwright: cannot parse JSON report: ${e.message || e}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (!report) {
|
|
206
|
+
const stdoutText = Buffer.concat(stdoutChunks).toString('utf-8');
|
|
207
|
+
try {
|
|
208
|
+
report = JSON.parse(stdoutText);
|
|
209
|
+
} catch {/* ignore */}
|
|
210
|
+
}
|
|
211
|
+
if (!report) {
|
|
212
|
+
color.error('Playwright: no JSON report produced.');
|
|
213
|
+
const tail = Buffer.concat(stderrChunks).toString('utf-8').slice(-2000);
|
|
214
|
+
return {
|
|
215
|
+
...empty,
|
|
216
|
+
failed: true,
|
|
217
|
+
failedAmount: 1,
|
|
218
|
+
verboseFailed: `Playwright: no JSON report. stderr tail:\n${tail}\n`
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
const pkgJson = JSON.parse(_fs.default.readFileSync(_path.default.join(pkgDir, 'package.json'), 'utf-8'));
|
|
222
|
+
const owner = pkgJson.author && (pkgJson.author.email || pkgJson.author) || '';
|
|
223
|
+
const pkgName = process.env.TARGET_PACKAGE || args.package || pkgJson.name || '';
|
|
224
|
+
const rows = [];
|
|
225
|
+
flattenSuites(report.suites, testDir, pkgName, typeof owner === 'string' ? owner : '', args.verbose === true, rows);
|
|
226
|
+
let passedAmount = 0;
|
|
227
|
+
let failedAmount = 0;
|
|
228
|
+
let skippedAmount = 0;
|
|
229
|
+
let verbosePassed = '';
|
|
230
|
+
let verboseFailed = '';
|
|
231
|
+
let verboseSkipped = '';
|
|
232
|
+
for (var r of rows) {
|
|
233
|
+
const line = `${r.category}: ${r.name} (${r.ms} ms)\n`;
|
|
234
|
+
if (r.skipped) {
|
|
235
|
+
skippedAmount++;
|
|
236
|
+
verboseSkipped += line;
|
|
237
|
+
} else if (r.success) {
|
|
238
|
+
passedAmount++;
|
|
239
|
+
verbosePassed += line;
|
|
240
|
+
} else {
|
|
241
|
+
failedAmount++;
|
|
242
|
+
verboseFailed += `${r.category}: ${r.name} (${r.ms} ms) : ${r.result}\n`;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
failed: failedAmount > 0,
|
|
247
|
+
passedAmount: passedAmount,
|
|
248
|
+
failedAmount: failedAmount,
|
|
249
|
+
skippedAmount: skippedAmount,
|
|
250
|
+
verbosePassed: verbosePassed,
|
|
251
|
+
verboseFailed: verboseFailed,
|
|
252
|
+
verboseSkipped: verboseSkipped,
|
|
253
|
+
csv: rowsToCsv(rows)
|
|
254
|
+
};
|
|
255
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "datagrok-tools",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.2.0",
|
|
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": {
|
|
@@ -14,13 +14,12 @@
|
|
|
14
14
|
"archiver-promise": "^1.0.0",
|
|
15
15
|
"datagrok-api": "^1.26.0",
|
|
16
16
|
"estraverse": "^5.3.0",
|
|
17
|
-
"glob": "^
|
|
17
|
+
"glob": "^13.0.6",
|
|
18
18
|
"ignore-walk": "^3.0.4",
|
|
19
19
|
"inquirer": "^7.3.3",
|
|
20
20
|
"js-yaml": "^4.1.0",
|
|
21
21
|
"minimist": "^1.2.8",
|
|
22
22
|
"node-fetch": "^2.7.0",
|
|
23
|
-
"node-recursive-directory": "^1.2.0",
|
|
24
23
|
"os": "^0.1.2",
|
|
25
24
|
"papaparse": "^5.4.1",
|
|
26
25
|
"path": "^0.12.7",
|