@sun-asterisk/sungen 2.6.1 → 2.6.3

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.
Files changed (62) hide show
  1. package/dist/cli/commands/dashboard.d.ts +10 -0
  2. package/dist/cli/commands/dashboard.d.ts.map +1 -0
  3. package/dist/cli/commands/dashboard.js +171 -0
  4. package/dist/cli/commands/dashboard.js.map +1 -0
  5. package/dist/cli/index.js +4 -2
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/dashboard/history-store.d.ts +27 -0
  8. package/dist/dashboard/history-store.d.ts.map +1 -0
  9. package/dist/dashboard/history-store.js +112 -0
  10. package/dist/dashboard/history-store.js.map +1 -0
  11. package/dist/dashboard/html-renderer.d.ts +30 -0
  12. package/dist/dashboard/html-renderer.d.ts.map +1 -0
  13. package/dist/dashboard/html-renderer.js +111 -0
  14. package/dist/dashboard/html-renderer.js.map +1 -0
  15. package/dist/dashboard/snapshot-builder.d.ts +30 -0
  16. package/dist/dashboard/snapshot-builder.d.ts.map +1 -0
  17. package/dist/dashboard/snapshot-builder.js +263 -0
  18. package/dist/dashboard/snapshot-builder.js.map +1 -0
  19. package/dist/dashboard/templates/index.html +287 -0
  20. package/dist/dashboard/types.d.ts +122 -0
  21. package/dist/dashboard/types.d.ts.map +1 -0
  22. package/dist/dashboard/types.js +11 -0
  23. package/dist/dashboard/types.js.map +1 -0
  24. package/dist/exporters/json-exporter.d.ts +25 -0
  25. package/dist/exporters/json-exporter.d.ts.map +1 -0
  26. package/dist/exporters/json-exporter.js +135 -0
  27. package/dist/exporters/json-exporter.js.map +1 -0
  28. package/dist/exporters/playwright-report-parser.d.ts +2 -1
  29. package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
  30. package/dist/exporters/playwright-report-parser.js +12 -5
  31. package/dist/exporters/playwright-report-parser.js.map +1 -1
  32. package/dist/exporters/spec-parser.d.ts.map +1 -1
  33. package/dist/exporters/spec-parser.js +8 -3
  34. package/dist/exporters/spec-parser.js.map +1 -1
  35. package/dist/orchestrator/ai-rules-updater.d.ts.map +1 -1
  36. package/dist/orchestrator/ai-rules-updater.js +4 -0
  37. package/dist/orchestrator/ai-rules-updater.js.map +1 -1
  38. package/dist/orchestrator/templates/ai-instructions/claude-cmd-dashboard.md +62 -0
  39. package/dist/orchestrator/templates/ai-instructions/claude-skill-dashboard.md +121 -0
  40. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-dashboard.md +62 -0
  41. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-dashboard.md +121 -0
  42. package/dist/orchestrator/templates/playwright.config.d.ts.map +1 -1
  43. package/dist/orchestrator/templates/playwright.config.js +17 -1
  44. package/dist/orchestrator/templates/playwright.config.js.map +1 -1
  45. package/dist/orchestrator/templates/playwright.config.ts +20 -1
  46. package/package.json +4 -3
  47. package/src/cli/commands/dashboard.ts +158 -0
  48. package/src/cli/index.ts +4 -2
  49. package/src/dashboard/history-store.ts +86 -0
  50. package/src/dashboard/html-renderer.ts +90 -0
  51. package/src/dashboard/snapshot-builder.ts +273 -0
  52. package/src/dashboard/templates/index.html +287 -0
  53. package/src/dashboard/types.ts +148 -0
  54. package/src/exporters/json-exporter.ts +162 -0
  55. package/src/exporters/playwright-report-parser.ts +12 -5
  56. package/src/exporters/spec-parser.ts +8 -3
  57. package/src/orchestrator/ai-rules-updater.ts +4 -0
  58. package/src/orchestrator/templates/ai-instructions/claude-cmd-dashboard.md +62 -0
  59. package/src/orchestrator/templates/ai-instructions/claude-skill-dashboard.md +121 -0
  60. package/src/orchestrator/templates/ai-instructions/copilot-cmd-dashboard.md +62 -0
  61. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-dashboard.md +121 -0
  62. package/src/orchestrator/templates/playwright.config.ts +20 -1
@@ -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 };