@sun-asterisk/sungen 2.6.1 → 2.6.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/dist/cli/commands/dashboard.d.ts +10 -0
- package/dist/cli/commands/dashboard.d.ts.map +1 -0
- package/dist/cli/commands/dashboard.js +171 -0
- package/dist/cli/commands/dashboard.js.map +1 -0
- package/dist/cli/index.js +4 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/history-store.d.ts +27 -0
- package/dist/dashboard/history-store.d.ts.map +1 -0
- package/dist/dashboard/history-store.js +112 -0
- package/dist/dashboard/history-store.js.map +1 -0
- package/dist/dashboard/html-renderer.d.ts +30 -0
- package/dist/dashboard/html-renderer.d.ts.map +1 -0
- package/dist/dashboard/html-renderer.js +111 -0
- package/dist/dashboard/html-renderer.js.map +1 -0
- package/dist/dashboard/snapshot-builder.d.ts +30 -0
- package/dist/dashboard/snapshot-builder.d.ts.map +1 -0
- package/dist/dashboard/snapshot-builder.js +263 -0
- package/dist/dashboard/snapshot-builder.js.map +1 -0
- package/dist/dashboard/templates/index.html +287 -0
- package/dist/dashboard/types.d.ts +122 -0
- package/dist/dashboard/types.d.ts.map +1 -0
- package/dist/dashboard/types.js +11 -0
- package/dist/dashboard/types.js.map +1 -0
- package/dist/exporters/json-exporter.d.ts +25 -0
- package/dist/exporters/json-exporter.d.ts.map +1 -0
- package/dist/exporters/json-exporter.js +135 -0
- package/dist/exporters/json-exporter.js.map +1 -0
- package/dist/exporters/playwright-report-parser.d.ts +2 -1
- package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
- package/dist/exporters/playwright-report-parser.js +12 -5
- package/dist/exporters/playwright-report-parser.js.map +1 -1
- package/dist/exporters/spec-parser.d.ts.map +1 -1
- package/dist/exporters/spec-parser.js +8 -3
- package/dist/exporters/spec-parser.js.map +1 -1
- package/dist/orchestrator/templates/playwright.config.d.ts.map +1 -1
- package/dist/orchestrator/templates/playwright.config.js +9 -1
- package/dist/orchestrator/templates/playwright.config.js.map +1 -1
- package/dist/orchestrator/templates/playwright.config.ts +11 -1
- package/package.json +4 -3
- package/src/cli/commands/dashboard.ts +158 -0
- package/src/cli/index.ts +4 -2
- package/src/dashboard/history-store.ts +86 -0
- package/src/dashboard/html-renderer.ts +90 -0
- package/src/dashboard/snapshot-builder.ts +273 -0
- package/src/dashboard/templates/index.html +287 -0
- package/src/dashboard/types.ts +148 -0
- package/src/exporters/json-exporter.ts +162 -0
- package/src/exporters/playwright-report-parser.ts +12 -5
- package/src/exporters/spec-parser.ts +8 -3
- package/src/orchestrator/templates/playwright.config.ts +11 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sungen dashboard` CLI command.
|
|
3
|
+
*
|
|
4
|
+
* Builds qa/dashboard/index.html — a single-file, share-ready test report —
|
|
5
|
+
* from existing Gherkin features, compiled .spec.ts files, and Playwright
|
|
6
|
+
* results. Snapshots are persisted under qa/dashboard/history/ (max 20).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Command } from 'commander';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import { execSync } from 'child_process';
|
|
13
|
+
import { EnvironmentInfo } from '../../exporters/types';
|
|
14
|
+
import {
|
|
15
|
+
buildDashboardSnapshot,
|
|
16
|
+
DashboardTarget,
|
|
17
|
+
listDashboardTargets,
|
|
18
|
+
resolveTargetType,
|
|
19
|
+
} from '../../dashboard/snapshot-builder';
|
|
20
|
+
import {
|
|
21
|
+
writeSnapshotToHistory,
|
|
22
|
+
DEFAULT_MAX_HISTORY,
|
|
23
|
+
} from '../../dashboard/history-store';
|
|
24
|
+
import { buildPayload, renderDashboardHtml } from '../../dashboard/html-renderer';
|
|
25
|
+
|
|
26
|
+
const COLOR = {
|
|
27
|
+
reset: '\x1b[0m',
|
|
28
|
+
gray: '\x1b[90m',
|
|
29
|
+
green: '\x1b[32m',
|
|
30
|
+
red: '\x1b[31m',
|
|
31
|
+
yellow: '\x1b[33m',
|
|
32
|
+
cyan: '\x1b[36m',
|
|
33
|
+
bold: '\x1b[1m',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function log(msg: string): void { console.log(msg); }
|
|
37
|
+
|
|
38
|
+
function getEnvironment(cwd: string): EnvironmentInfo {
|
|
39
|
+
let baseURL = '';
|
|
40
|
+
let projectName = 'chromium';
|
|
41
|
+
const configPath = path.join(cwd, 'playwright.config.ts');
|
|
42
|
+
if (fs.existsSync(configPath)) {
|
|
43
|
+
const c = fs.readFileSync(configPath, 'utf-8');
|
|
44
|
+
const baseMatch = c.match(/baseURL:\s*['"]([^'"]+)['"]/);
|
|
45
|
+
if (baseMatch) baseURL = baseMatch[1];
|
|
46
|
+
const projMatch = c.match(/name:\s*['"]([^'"]+)['"]/);
|
|
47
|
+
if (projMatch) projectName = projMatch[1];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let executor = process.env.CI_USER || process.env.USER || '';
|
|
51
|
+
try {
|
|
52
|
+
const gitUser = execSync('git config user.name', { cwd, encoding: 'utf-8' }).trim();
|
|
53
|
+
if (gitUser) executor = gitUser;
|
|
54
|
+
} catch { /* ignore */ }
|
|
55
|
+
|
|
56
|
+
return { baseURL, projectName, executor };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface DashboardOptions {
|
|
60
|
+
noHistory?: boolean;
|
|
61
|
+
open?: boolean;
|
|
62
|
+
maxHistory?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function registerDashboardCommand(program: Command): void {
|
|
66
|
+
program
|
|
67
|
+
.command('dashboard')
|
|
68
|
+
.description('Build a single-file HTML dashboard summarising all test cases & results')
|
|
69
|
+
.argument('[names...]', 'Specific screen or flow names. Omit to include all.')
|
|
70
|
+
.option('--no-history', 'Do not persist this run under qa/dashboard/history/')
|
|
71
|
+
.option('--max-history <n>', `Cap retained history files (default: ${DEFAULT_MAX_HISTORY})`)
|
|
72
|
+
.option('--open', 'Open the rendered HTML in the default browser when done')
|
|
73
|
+
.action(async (names: string[], options: DashboardOptions) => {
|
|
74
|
+
try {
|
|
75
|
+
const cwd = process.cwd();
|
|
76
|
+
|
|
77
|
+
// 1. Scope detection
|
|
78
|
+
let targets: DashboardTarget[];
|
|
79
|
+
if (names && names.length > 0) {
|
|
80
|
+
targets = names.map((n) => resolveTargetType(cwd, n));
|
|
81
|
+
} else {
|
|
82
|
+
targets = listDashboardTargets(cwd);
|
|
83
|
+
if (targets.length === 0) {
|
|
84
|
+
console.error(`${COLOR.red}No screens or flows found in qa/screens/ or qa/flows/${COLOR.reset}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const labels = targets.map((t) => t.isFlow ? `flow/${t.name}` : t.name);
|
|
90
|
+
log(`${COLOR.bold}sungen dashboard${COLOR.reset} — building report for ${targets.length} target(s): ${labels.join(', ')}`);
|
|
91
|
+
|
|
92
|
+
// 2. Build snapshot
|
|
93
|
+
const env = getEnvironment(cwd);
|
|
94
|
+
const snapshot = buildDashboardSnapshot({
|
|
95
|
+
cwd,
|
|
96
|
+
targets,
|
|
97
|
+
env,
|
|
98
|
+
continueOnError: true,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
log(
|
|
102
|
+
`\n${COLOR.bold}Snapshot${COLOR.reset} ${snapshot.runId} ` +
|
|
103
|
+
`${COLOR.gray}(${snapshot.screens.length} screens, ${snapshot.summary.total} TCs)${COLOR.reset}`
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// 3. Persist history (unless disabled)
|
|
107
|
+
if (!options.noHistory) {
|
|
108
|
+
const max = options.maxHistory ? Math.max(1, parseInt(options.maxHistory, 10) || DEFAULT_MAX_HISTORY) : DEFAULT_MAX_HISTORY;
|
|
109
|
+
const result = writeSnapshotToHistory(cwd, snapshot, max);
|
|
110
|
+
log(` ${COLOR.green}✓${COLOR.reset} history ${path.relative(cwd, result.written)}`);
|
|
111
|
+
if (result.pruned.length > 0) {
|
|
112
|
+
log(` ${COLOR.gray} pruned ${result.pruned.length} older snapshot(s)${COLOR.reset}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 4. Render HTML
|
|
117
|
+
const payload = buildPayload(cwd, snapshot);
|
|
118
|
+
const rendered = renderDashboardHtml(cwd, payload);
|
|
119
|
+
log(` ${COLOR.green}✓${COLOR.reset} html ${path.relative(cwd, rendered.outputPath)} ${COLOR.gray}(${(rendered.bytes / 1024 / 1024).toFixed(2)} MB)${COLOR.reset}`);
|
|
120
|
+
|
|
121
|
+
// 5. Print quick summary
|
|
122
|
+
const s = snapshot.summary;
|
|
123
|
+
log('');
|
|
124
|
+
log(` ${COLOR.bold}Summary${COLOR.reset}`);
|
|
125
|
+
log(` Total: ${s.total}`);
|
|
126
|
+
log(` ${COLOR.green}Passed${COLOR.reset}: ${s.passed}`);
|
|
127
|
+
log(` ${COLOR.red}Failed${COLOR.reset}: ${s.failed}`);
|
|
128
|
+
log(` Pending: ${s.pending}`);
|
|
129
|
+
log(` N/A: ${s.na}`);
|
|
130
|
+
if (s.notCompiled > 0) {
|
|
131
|
+
log(` ${COLOR.yellow}Not compiled${COLOR.reset}: ${s.notCompiled}`);
|
|
132
|
+
}
|
|
133
|
+
const passRatePct = (s.passRate * 100).toFixed(1);
|
|
134
|
+
log(` Pass rate: ${passRatePct}% ${COLOR.gray}(of executed)${COLOR.reset}`);
|
|
135
|
+
log('');
|
|
136
|
+
|
|
137
|
+
// 6. Optionally open in browser
|
|
138
|
+
if (options.open) {
|
|
139
|
+
openInBrowser(rendered.outputPath);
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error(`${COLOR.red}Fatal:${COLOR.reset} ${err instanceof Error ? err.message : err}`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function openInBrowser(filePath: string): void {
|
|
149
|
+
const fileUrl = `file://${filePath}`;
|
|
150
|
+
const platform = process.platform;
|
|
151
|
+
try {
|
|
152
|
+
if (platform === 'darwin') execSync(`open "${fileUrl}"`);
|
|
153
|
+
else if (platform === 'win32') execSync(`start "" "${fileUrl}"`);
|
|
154
|
+
else execSync(`xdg-open "${fileUrl}"`);
|
|
155
|
+
} catch {
|
|
156
|
+
// ignore — user can open manually
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { registerUpdateCommand } from './commands/update';
|
|
|
13
13
|
import { registerDeliveryCommand } from './commands/delivery';
|
|
14
14
|
import { registerFigmaCommand } from './commands/figma';
|
|
15
15
|
import { registerAddFlowCommand } from './commands/add-flow';
|
|
16
|
+
import { registerDashboardCommand } from './commands/dashboard';
|
|
16
17
|
|
|
17
18
|
async function main() {
|
|
18
19
|
const program = new Command();
|
|
@@ -20,13 +21,13 @@ async function main() {
|
|
|
20
21
|
program
|
|
21
22
|
.name('sungen')
|
|
22
23
|
.description('Deterministic E2E Test Compiler — Gherkin + Selectors → Playwright')
|
|
23
|
-
.version('2.6.
|
|
24
|
+
.version('2.6.2');
|
|
24
25
|
|
|
25
26
|
// Global options
|
|
26
27
|
program
|
|
27
28
|
.option('-v, --verbose', 'Enable verbose logging');
|
|
28
29
|
|
|
29
|
-
// Register commands (
|
|
30
|
+
// Register commands (9)
|
|
30
31
|
registerInitCommand(program);
|
|
31
32
|
registerAddCommand(program);
|
|
32
33
|
registerGenerateCommand(program);
|
|
@@ -35,6 +36,7 @@ async function main() {
|
|
|
35
36
|
registerDeliveryCommand(program);
|
|
36
37
|
registerFigmaCommand(program);
|
|
37
38
|
registerAddFlowCommand(program);
|
|
39
|
+
registerDashboardCommand(program);
|
|
38
40
|
|
|
39
41
|
await program.parseAsync(process.argv);
|
|
40
42
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persist dashboard snapshots under qa/dashboard/history/ and prune to a fixed
|
|
3
|
+
* max count (oldest by mtime are deleted). Used by `sungen dashboard` to
|
|
4
|
+
* accumulate runs for Trends / Compare views.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import { DashboardSnapshot } from './types';
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_MAX_HISTORY = 20;
|
|
12
|
+
|
|
13
|
+
export interface HistoryWriteResult {
|
|
14
|
+
written: string; // absolute path written
|
|
15
|
+
pruned: string[]; // absolute paths removed
|
|
16
|
+
retained: string[]; // absolute paths still on disk (newest → oldest)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function historyDir(cwd: string): string {
|
|
20
|
+
return path.join(cwd, 'qa', 'dashboard', 'history');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Write `snapshot` as <runId>.json and prune older files until at most `max`
|
|
25
|
+
* files remain. Files are sorted by mtime so manual edits (rare) still work.
|
|
26
|
+
*/
|
|
27
|
+
export function writeSnapshotToHistory(
|
|
28
|
+
cwd: string,
|
|
29
|
+
snapshot: DashboardSnapshot,
|
|
30
|
+
max: number = DEFAULT_MAX_HISTORY
|
|
31
|
+
): HistoryWriteResult {
|
|
32
|
+
const dir = historyDir(cwd);
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
|
|
35
|
+
const filename = `${snapshot.runId}.json`;
|
|
36
|
+
const target = path.join(dir, filename);
|
|
37
|
+
fs.writeFileSync(target, JSON.stringify(snapshot, null, 2), 'utf-8');
|
|
38
|
+
|
|
39
|
+
const pruned = pruneHistory(dir, max);
|
|
40
|
+
|
|
41
|
+
const retained = listHistoryFiles(dir);
|
|
42
|
+
return { written: target, pruned, retained };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Return absolute paths of every JSON file under history/, sorted newest → oldest.
|
|
47
|
+
*/
|
|
48
|
+
export function listHistoryFiles(dir: string): string[] {
|
|
49
|
+
if (!fs.existsSync(dir)) return [];
|
|
50
|
+
return fs.readdirSync(dir)
|
|
51
|
+
.filter((f) => f.endsWith('.json'))
|
|
52
|
+
.map((f) => path.join(dir, f))
|
|
53
|
+
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read every history JSON, oldest → newest. Skips files that fail to parse.
|
|
58
|
+
*/
|
|
59
|
+
export function readHistory(cwd: string): DashboardSnapshot[] {
|
|
60
|
+
const dir = historyDir(cwd);
|
|
61
|
+
const files = listHistoryFiles(dir).reverse(); // oldest → newest
|
|
62
|
+
const out: DashboardSnapshot[] = [];
|
|
63
|
+
for (const f of files) {
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(fs.readFileSync(f, 'utf-8')) as DashboardSnapshot;
|
|
66
|
+
out.push(parsed);
|
|
67
|
+
} catch {
|
|
68
|
+
// ignore malformed
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Delete oldest files in `dir` until at most `max` remain.
|
|
76
|
+
* Returns the absolute paths removed.
|
|
77
|
+
*/
|
|
78
|
+
function pruneHistory(dir: string, max: number): string[] {
|
|
79
|
+
const files = listHistoryFiles(dir); // newest first
|
|
80
|
+
if (files.length <= max) return [];
|
|
81
|
+
const toRemove = files.slice(max);
|
|
82
|
+
for (const f of toRemove) {
|
|
83
|
+
try { fs.unlinkSync(f); } catch { /* ignore */ }
|
|
84
|
+
}
|
|
85
|
+
return toRemove;
|
|
86
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inject a DashboardPayload into the pre-built single-file HTML template,
|
|
3
|
+
* then write the result to qa/dashboard/index.html.
|
|
4
|
+
*
|
|
5
|
+
* The template lives at src/dashboard/templates/index.html (built by
|
|
6
|
+
* dashboard/, then copied during `npm run build`). It must contain
|
|
7
|
+
* the placeholder line:
|
|
8
|
+
*
|
|
9
|
+
* <script id="__SUNGEN_DASHBOARD__" type="application/json">{}</script>
|
|
10
|
+
*
|
|
11
|
+
* The renderer replaces the JSON content with the payload — no other
|
|
12
|
+
* mutation. This keeps the template itself fully static, so opening it
|
|
13
|
+
* directly (without running the CLI) shows an empty-state dashboard.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from 'fs';
|
|
17
|
+
import * as path from 'path';
|
|
18
|
+
import { DashboardPayload, DashboardSnapshot } from './types';
|
|
19
|
+
import { readHistory } from './history-store';
|
|
20
|
+
|
|
21
|
+
const PAYLOAD_TAG_OPEN = '<script id="__SUNGEN_DASHBOARD__" type="application/json">';
|
|
22
|
+
const PAYLOAD_TAG_CLOSE = '</script>';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Locate the bundled template. Looks first in dist/ (production), then src/
|
|
26
|
+
* (when running via tsx in dev).
|
|
27
|
+
*/
|
|
28
|
+
export function resolveTemplatePath(): string {
|
|
29
|
+
const candidates = [
|
|
30
|
+
// Compiled package: dist/dashboard/templates/index.html (copy-templates)
|
|
31
|
+
path.join(__dirname, 'templates', 'index.html'),
|
|
32
|
+
// Dev: src/dashboard/templates/index.html
|
|
33
|
+
path.join(__dirname, '..', '..', 'src', 'dashboard', 'templates', 'index.html'),
|
|
34
|
+
];
|
|
35
|
+
for (const c of candidates) {
|
|
36
|
+
if (fs.existsSync(c)) return c;
|
|
37
|
+
}
|
|
38
|
+
throw new Error(
|
|
39
|
+
'Dashboard HTML template not found. Build it via:\n' +
|
|
40
|
+
' cd dashboard && npm install && npm run build\n' +
|
|
41
|
+
'then copy dashboard/dist/index.html → src/dashboard/templates/index.html'
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildPayload(cwd: string, current: DashboardSnapshot): DashboardPayload {
|
|
46
|
+
const all = readHistory(cwd);
|
|
47
|
+
// Exclude `current` from history if it's been written there already.
|
|
48
|
+
const history = all.filter((s) => s.runId !== current.runId);
|
|
49
|
+
return { current, history };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RenderResult {
|
|
53
|
+
outputPath: string;
|
|
54
|
+
bytes: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Render `payload` into the template and write to qa/dashboard/index.html.
|
|
59
|
+
*/
|
|
60
|
+
export function renderDashboardHtml(cwd: string, payload: DashboardPayload): RenderResult {
|
|
61
|
+
const templatePath = resolveTemplatePath();
|
|
62
|
+
const template = fs.readFileSync(templatePath, 'utf-8');
|
|
63
|
+
|
|
64
|
+
const json = JSON.stringify(payload);
|
|
65
|
+
const replaced = injectPayload(template, json);
|
|
66
|
+
|
|
67
|
+
const outDir = path.join(cwd, 'qa', 'dashboard');
|
|
68
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
69
|
+
const outputPath = path.join(outDir, 'index.html');
|
|
70
|
+
fs.writeFileSync(outputPath, replaced, 'utf-8');
|
|
71
|
+
|
|
72
|
+
return { outputPath, bytes: Buffer.byteLength(replaced, 'utf-8') };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function injectPayload(template: string, json: string): string {
|
|
76
|
+
const start = template.indexOf(PAYLOAD_TAG_OPEN);
|
|
77
|
+
if (start === -1) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Template is missing the payload placeholder ${PAYLOAD_TAG_OPEN}…${PAYLOAD_TAG_CLOSE}`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const contentStart = start + PAYLOAD_TAG_OPEN.length;
|
|
83
|
+
const contentEnd = template.indexOf(PAYLOAD_TAG_CLOSE, contentStart);
|
|
84
|
+
if (contentEnd === -1) {
|
|
85
|
+
throw new Error('Template payload placeholder is unterminated');
|
|
86
|
+
}
|
|
87
|
+
// Escape </script> inside JSON to prevent breaking the surrounding script tag.
|
|
88
|
+
const safe = json.replace(/<\/script>/gi, '<\\/script>');
|
|
89
|
+
return template.slice(0, contentStart) + safe + template.slice(contentEnd);
|
|
90
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrate dashboard snapshot construction across all screens/flows.
|
|
3
|
+
*
|
|
4
|
+
* Inputs are read from the same on-disk sources used by `sungen delivery`:
|
|
5
|
+
* qa/screens/<name>/... feature, test-data, selectors, requirements
|
|
6
|
+
* qa/flows/<name>/...
|
|
7
|
+
* specs/generated/<...>/... compiled .spec.ts + per-target test-result.json
|
|
8
|
+
* test-results/results.json global fallback
|
|
9
|
+
*
|
|
10
|
+
* Output is a DashboardSnapshot ready to be embedded in HTML or written as
|
|
11
|
+
* a history entry under qa/dashboard/history/<runId>.json.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
import { execSync } from 'child_process';
|
|
17
|
+
import { parseFeatureMetadata } from '../exporters/feature-parser';
|
|
18
|
+
import { parseSpecFile } from '../exporters/spec-parser';
|
|
19
|
+
import { loadTestData } from '../exporters/test-data-resolver';
|
|
20
|
+
import { loadPlaywrightReport } from '../exporters/playwright-report-parser';
|
|
21
|
+
import { mergeFeatureAndSpec } from '../exporters/scenario-merger';
|
|
22
|
+
import { buildScreenSnapshot } from '../exporters/json-exporter';
|
|
23
|
+
import { getPackageVersion } from '../exporters/package-info';
|
|
24
|
+
import { EnvironmentInfo } from '../exporters/types';
|
|
25
|
+
import {
|
|
26
|
+
AggregateSummary,
|
|
27
|
+
DashboardSnapshot,
|
|
28
|
+
ScenarioSnapshot,
|
|
29
|
+
ScreenSnapshot,
|
|
30
|
+
SNAPSHOT_VERSION,
|
|
31
|
+
} from './types';
|
|
32
|
+
|
|
33
|
+
export interface DashboardTarget {
|
|
34
|
+
name: string;
|
|
35
|
+
isFlow: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BuildSnapshotOptions {
|
|
39
|
+
cwd: string;
|
|
40
|
+
targets: DashboardTarget[];
|
|
41
|
+
env: EnvironmentInfo;
|
|
42
|
+
/** Skip targets where the .feature/.spec.ts pair fails to parse. */
|
|
43
|
+
continueOnError?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function buildDashboardSnapshot(options: BuildSnapshotOptions): DashboardSnapshot {
|
|
47
|
+
const { cwd, targets, env } = options;
|
|
48
|
+
const screens: ScreenSnapshot[] = [];
|
|
49
|
+
|
|
50
|
+
for (const target of targets) {
|
|
51
|
+
try {
|
|
52
|
+
const screen = buildOneScreen(cwd, target, env);
|
|
53
|
+
if (screen) screens.push(screen);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (!options.continueOnError) throw err;
|
|
56
|
+
// eslint-disable-next-line no-console
|
|
57
|
+
console.warn(`[dashboard] skipping ${target.name}: ${(err as Error).message}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const now = new Date();
|
|
62
|
+
return {
|
|
63
|
+
version: SNAPSHOT_VERSION,
|
|
64
|
+
runId: makeRunId(now),
|
|
65
|
+
generatedAt: toLocalIso(now),
|
|
66
|
+
environment: collectEnvironment(cwd, env),
|
|
67
|
+
summary: aggregateSummary(screens),
|
|
68
|
+
screens: screens.sort((a, b) => a.name.localeCompare(b.name)),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ----------------------------------------------------------------------------
|
|
73
|
+
// Per-target build
|
|
74
|
+
// ----------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
function buildOneScreen(
|
|
77
|
+
cwd: string,
|
|
78
|
+
target: DashboardTarget,
|
|
79
|
+
env: EnvironmentInfo
|
|
80
|
+
): ScreenSnapshot | null {
|
|
81
|
+
const base = qaDir(cwd, target);
|
|
82
|
+
const genBase = generatedDir(cwd, target);
|
|
83
|
+
const featureFile = path.join(base, 'features', `${target.name}.feature`);
|
|
84
|
+
const testDataFile = path.join(base, 'test-data', `${target.name}.yaml`);
|
|
85
|
+
const specFile = path.join(genBase, `${target.name}.spec.ts`);
|
|
86
|
+
const specMdFile = path.join(base, 'requirements', 'spec.md');
|
|
87
|
+
const resultsFile = resolveResultsPath(cwd, target);
|
|
88
|
+
|
|
89
|
+
if (!fs.existsSync(featureFile)) return null;
|
|
90
|
+
|
|
91
|
+
const feature = parseFeatureMetadata(featureFile);
|
|
92
|
+
const spec = fs.existsSync(specFile)
|
|
93
|
+
? parseSpecFile(specFile)
|
|
94
|
+
: { tests: [] };
|
|
95
|
+
const testData = fs.existsSync(testDataFile) ? loadTestData(testDataFile) : {};
|
|
96
|
+
const results = resultsFile ? loadPlaywrightReport(resultsFile) : null;
|
|
97
|
+
|
|
98
|
+
const merged = mergeFeatureAndSpec(feature, spec);
|
|
99
|
+
const label = target.isFlow ? `flow/${target.name}` : target.name;
|
|
100
|
+
const specLink = fs.existsSync(specMdFile) ? path.relative(cwd, specMdFile) : undefined;
|
|
101
|
+
|
|
102
|
+
return buildScreenSnapshot({
|
|
103
|
+
screen: label,
|
|
104
|
+
isFlow: target.isFlow,
|
|
105
|
+
featureName: feature.featureName,
|
|
106
|
+
featurePath: feature.featurePath,
|
|
107
|
+
specLink,
|
|
108
|
+
merged,
|
|
109
|
+
testData,
|
|
110
|
+
results,
|
|
111
|
+
env,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ----------------------------------------------------------------------------
|
|
116
|
+
// Aggregate summary across all screens
|
|
117
|
+
// ----------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
function aggregateSummary(screens: ScreenSnapshot[]): AggregateSummary {
|
|
120
|
+
const summary: AggregateSummary = {
|
|
121
|
+
total: 0,
|
|
122
|
+
passed: 0,
|
|
123
|
+
failed: 0,
|
|
124
|
+
pending: 0,
|
|
125
|
+
na: 0,
|
|
126
|
+
notCompiled: 0,
|
|
127
|
+
passRate: 0,
|
|
128
|
+
byPriority: { Critical: 0, High: 0, Normal: 0, Low: 0 },
|
|
129
|
+
byCategory: { Accessing: 0, GUI: 0, Function: 0 },
|
|
130
|
+
byType: { Auto: 0, Manual: 0, 'Not compiled': 0 },
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
for (const screen of screens) {
|
|
134
|
+
summary.total += screen.summary.total;
|
|
135
|
+
summary.passed += screen.summary.passed;
|
|
136
|
+
summary.failed += screen.summary.failed;
|
|
137
|
+
summary.pending += screen.summary.pending;
|
|
138
|
+
summary.na += screen.summary.na;
|
|
139
|
+
summary.notCompiled += screen.summary.notCompiled;
|
|
140
|
+
|
|
141
|
+
for (const s of screen.scenarios) {
|
|
142
|
+
bumpKey(summary.byPriority, s.priority);
|
|
143
|
+
bumpKey(summary.byCategory, s.category2);
|
|
144
|
+
bumpKey(summary.byType, s.type);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const executed = summary.passed + summary.failed;
|
|
149
|
+
summary.passRate = executed > 0 ? summary.passed / executed : 0;
|
|
150
|
+
return summary;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function bumpKey(record: Record<string, number>, key: string): void {
|
|
154
|
+
record[key] = (record[key] ?? 0) + 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ----------------------------------------------------------------------------
|
|
158
|
+
// Path helpers (mirror delivery.ts to stay consistent)
|
|
159
|
+
// ----------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
function qaDir(cwd: string, target: DashboardTarget): string {
|
|
162
|
+
return path.join(cwd, 'qa', target.isFlow ? 'flows' : 'screens', target.name);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function generatedDir(cwd: string, target: DashboardTarget): string {
|
|
166
|
+
return target.isFlow
|
|
167
|
+
? path.join(cwd, 'specs', 'generated', 'flows', target.name)
|
|
168
|
+
: path.join(cwd, 'specs', 'generated', target.name);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resolveResultsPath(cwd: string, target: DashboardTarget): string | null {
|
|
172
|
+
const genDir = generatedDir(cwd, target);
|
|
173
|
+
const perTarget = path.join(genDir, `${target.name}-test-result.json`);
|
|
174
|
+
if (fs.existsSync(perTarget)) return perTarget;
|
|
175
|
+
const global = path.join(cwd, 'test-results', 'results.json');
|
|
176
|
+
if (fs.existsSync(global)) return global;
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ----------------------------------------------------------------------------
|
|
181
|
+
// Discovery (also used by CLI)
|
|
182
|
+
// ----------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
export function listDashboardTargets(cwd: string): DashboardTarget[] {
|
|
185
|
+
const targets: DashboardTarget[] = [];
|
|
186
|
+
|
|
187
|
+
const screensDir = path.join(cwd, 'qa', 'screens');
|
|
188
|
+
if (fs.existsSync(screensDir)) {
|
|
189
|
+
for (const d of fs.readdirSync(screensDir, { withFileTypes: true })) {
|
|
190
|
+
if (d.isDirectory()) targets.push({ name: d.name, isFlow: false });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const flowsDir = path.join(cwd, 'qa', 'flows');
|
|
195
|
+
if (fs.existsSync(flowsDir)) {
|
|
196
|
+
for (const d of fs.readdirSync(flowsDir, { withFileTypes: true })) {
|
|
197
|
+
if (d.isDirectory()) targets.push({ name: d.name, isFlow: true });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return targets.sort((a, b) => a.name.localeCompare(b.name));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function resolveTargetType(cwd: string, name: string): DashboardTarget {
|
|
205
|
+
if (fs.existsSync(path.join(cwd, 'qa', 'flows', name))) {
|
|
206
|
+
return { name, isFlow: true };
|
|
207
|
+
}
|
|
208
|
+
return { name, isFlow: false };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ----------------------------------------------------------------------------
|
|
212
|
+
// Run ID + environment metadata (Vietnam Indochina Time, UTC+7, no DST)
|
|
213
|
+
// ----------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
const VN_OFFSET_MS = 7 * 60 * 60 * 1000;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Filename-safe ISO timestamp in Vietnam time (UTC+7).
|
|
219
|
+
* Example: 2026-04-29T08-13-53+0700
|
|
220
|
+
*
|
|
221
|
+
* Colons in HH:MM:SS are replaced with `-` (filename-safe on Windows too).
|
|
222
|
+
* The `+0700` suffix makes the offset explicit so consumers don't need
|
|
223
|
+
* to assume a default timezone.
|
|
224
|
+
*/
|
|
225
|
+
function makeRunId(now: Date): string {
|
|
226
|
+
const vn = new Date(now.getTime() + VN_OFFSET_MS);
|
|
227
|
+
const yyyy = vn.getUTCFullYear();
|
|
228
|
+
const MM = String(vn.getUTCMonth() + 1).padStart(2, '0');
|
|
229
|
+
const dd = String(vn.getUTCDate()).padStart(2, '0');
|
|
230
|
+
const HH = String(vn.getUTCHours()).padStart(2, '0');
|
|
231
|
+
const mm = String(vn.getUTCMinutes()).padStart(2, '0');
|
|
232
|
+
const ss = String(vn.getUTCSeconds()).padStart(2, '0');
|
|
233
|
+
return `${yyyy}-${MM}-${dd}T${HH}-${mm}-${ss}+0700`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Full ISO 8601 timestamp in Vietnam time, with milliseconds and `+07:00` offset.
|
|
238
|
+
* Example: 2026-04-29T08:13:53.495+07:00
|
|
239
|
+
*/
|
|
240
|
+
function toLocalIso(now: Date): string {
|
|
241
|
+
const vn = new Date(now.getTime() + VN_OFFSET_MS);
|
|
242
|
+
const yyyy = vn.getUTCFullYear();
|
|
243
|
+
const MM = String(vn.getUTCMonth() + 1).padStart(2, '0');
|
|
244
|
+
const dd = String(vn.getUTCDate()).padStart(2, '0');
|
|
245
|
+
const HH = String(vn.getUTCHours()).padStart(2, '0');
|
|
246
|
+
const mm = String(vn.getUTCMinutes()).padStart(2, '0');
|
|
247
|
+
const ss = String(vn.getUTCSeconds()).padStart(2, '0');
|
|
248
|
+
const ms = String(vn.getUTCMilliseconds()).padStart(3, '0');
|
|
249
|
+
return `${yyyy}-${MM}-${dd}T${HH}:${mm}:${ss}.${ms}+07:00`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function collectEnvironment(cwd: string, env: EnvironmentInfo) {
|
|
253
|
+
let gitBranch: string | undefined;
|
|
254
|
+
let gitSha: string | undefined;
|
|
255
|
+
try {
|
|
256
|
+
gitBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd, encoding: 'utf-8' }).trim();
|
|
257
|
+
} catch { /* ignore */ }
|
|
258
|
+
try {
|
|
259
|
+
gitSha = execSync('git rev-parse --short HEAD', { cwd, encoding: 'utf-8' }).trim();
|
|
260
|
+
} catch { /* ignore */ }
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
baseURL: env.baseURL,
|
|
264
|
+
projectName: env.projectName,
|
|
265
|
+
executor: env.executor,
|
|
266
|
+
sungenVersion: getPackageVersion(),
|
|
267
|
+
gitBranch,
|
|
268
|
+
gitSha,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Helper for ScenarioSnapshot tree filters in UI (re-exported for callers)
|
|
273
|
+
export type { ScenarioSnapshot };
|