@sun-asterisk/sungen 3.2.3 → 3.2.4

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 (160) hide show
  1. package/dist/cli/commands/feedback.d.ts +11 -0
  2. package/dist/cli/commands/feedback.d.ts.map +1 -1
  3. package/dist/cli/commands/feedback.js +154 -57
  4. package/dist/cli/commands/feedback.js.map +1 -1
  5. package/dist/cli/commands/login.d.ts +19 -0
  6. package/dist/cli/commands/login.d.ts.map +1 -0
  7. package/dist/cli/commands/login.js +76 -0
  8. package/dist/cli/commands/login.js.map +1 -0
  9. package/dist/cli/commands/qa-feedback.d.ts +14 -0
  10. package/dist/cli/commands/qa-feedback.d.ts.map +1 -0
  11. package/dist/cli/commands/qa-feedback.js +83 -0
  12. package/dist/cli/commands/qa-feedback.js.map +1 -0
  13. package/dist/cli/index.js +4 -0
  14. package/dist/cli/index.js.map +1 -1
  15. package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
  16. package/dist/dashboard/snapshot-builder.js +108 -33
  17. package/dist/dashboard/snapshot-builder.js.map +1 -1
  18. package/dist/dashboard/templates/index.html +23 -23
  19. package/dist/dashboard/types.d.ts +4 -0
  20. package/dist/dashboard/types.d.ts.map +1 -1
  21. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  22. package/dist/exporters/csv-exporter.js +11 -4
  23. package/dist/exporters/csv-exporter.js.map +1 -1
  24. package/dist/exporters/feature-parser.d.ts +4 -0
  25. package/dist/exporters/feature-parser.d.ts.map +1 -1
  26. package/dist/exporters/feature-parser.js +7 -2
  27. package/dist/exporters/feature-parser.js.map +1 -1
  28. package/dist/exporters/json-exporter.d.ts.map +1 -1
  29. package/dist/exporters/json-exporter.js +11 -3
  30. package/dist/exporters/json-exporter.js.map +1 -1
  31. package/dist/exporters/playwright-report-parser.d.ts +21 -2
  32. package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
  33. package/dist/exporters/playwright-report-parser.js +52 -12
  34. package/dist/exporters/playwright-report-parser.js.map +1 -1
  35. package/dist/exporters/scenario-merger.d.ts.map +1 -1
  36. package/dist/exporters/scenario-merger.js +50 -2
  37. package/dist/exporters/scenario-merger.js.map +1 -1
  38. package/dist/exporters/spec-parser.js +1 -1
  39. package/dist/exporters/spec-parser.js.map +1 -1
  40. package/dist/exporters/test-data-resolver.d.ts +7 -0
  41. package/dist/exporters/test-data-resolver.d.ts.map +1 -1
  42. package/dist/exporters/test-data-resolver.js +13 -0
  43. package/dist/exporters/test-data-resolver.js.map +1 -1
  44. package/dist/exporters/types.d.ts +6 -0
  45. package/dist/exporters/types.d.ts.map +1 -1
  46. package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
  47. package/dist/exporters/xlsx-exporter.js +131 -42
  48. package/dist/exporters/xlsx-exporter.js.map +1 -1
  49. package/dist/generators/gherkin-parser/index.d.ts +15 -0
  50. package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
  51. package/dist/generators/gherkin-parser/index.js +33 -1
  52. package/dist/generators/gherkin-parser/index.js.map +1 -1
  53. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/check-action.hbs +29 -8
  54. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/scroll-action.hbs +21 -17
  55. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/toggle-action.hbs +3 -0
  56. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/uncheck-action.hbs +26 -6
  57. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/is-hidden-assertion.hbs +3 -1
  58. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-assertion.hbs +14 -0
  59. package/dist/generators/test-generator/adapters/appium/templates/steps/gestures/tap-top-action.hbs +3 -0
  60. package/dist/generators/test-generator/adapters/appium/templates/steps/partials/locator.hbs +9 -2
  61. package/dist/generators/test-generator/adapters/appium/templates/test-file.hbs +115 -0
  62. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row.hbs +1 -1
  63. package/dist/harness/script-check.d.ts.map +1 -1
  64. package/dist/harness/script-check.js +10 -5
  65. package/dist/harness/script-check.js.map +1 -1
  66. package/dist/orchestrator/ai-skills/writer.d.ts.map +1 -1
  67. package/dist/orchestrator/ai-skills/writer.js +46 -4
  68. package/dist/orchestrator/ai-skills/writer.js.map +1 -1
  69. package/dist/orchestrator/templates/ai-src/commands/add-flow.md +52 -5
  70. package/dist/orchestrator/templates/ai-src/commands/create-test.md +7 -0
  71. package/dist/orchestrator/templates/ai-src/commands/feedback.md +6 -6
  72. package/dist/orchestrator/templates/ai-src/commands/locale.md +26 -4
  73. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +5 -4
  74. package/dist/orchestrator/templates/wdio.conf.ts +39 -3
  75. package/dist/tools/sungen-api/edit-in-editor.d.ts +15 -0
  76. package/dist/tools/sungen-api/edit-in-editor.d.ts.map +1 -0
  77. package/dist/tools/sungen-api/edit-in-editor.js +85 -0
  78. package/dist/tools/sungen-api/edit-in-editor.js.map +1 -0
  79. package/dist/tools/sungen-api/feedback-client.d.ts +24 -0
  80. package/dist/tools/sungen-api/feedback-client.d.ts.map +1 -0
  81. package/dist/tools/sungen-api/feedback-client.js +53 -0
  82. package/dist/tools/sungen-api/feedback-client.js.map +1 -0
  83. package/dist/tools/sungen-api/read-stdin.d.ts +6 -0
  84. package/dist/tools/sungen-api/read-stdin.d.ts.map +1 -0
  85. package/dist/tools/sungen-api/read-stdin.js +19 -0
  86. package/dist/tools/sungen-api/read-stdin.js.map +1 -0
  87. package/dist/tools/sungen-auth/api-base-url.d.ts +14 -0
  88. package/dist/tools/sungen-auth/api-base-url.d.ts.map +1 -0
  89. package/dist/tools/sungen-auth/api-base-url.js +28 -0
  90. package/dist/tools/sungen-auth/api-base-url.js.map +1 -0
  91. package/dist/tools/sungen-auth/credentials-store.d.ts +19 -0
  92. package/dist/tools/sungen-auth/credentials-store.d.ts.map +1 -0
  93. package/dist/tools/sungen-auth/credentials-store.js +94 -0
  94. package/dist/tools/sungen-auth/credentials-store.js.map +1 -0
  95. package/dist/tools/sungen-auth/login-flow.d.ts +33 -0
  96. package/dist/tools/sungen-auth/login-flow.d.ts.map +1 -0
  97. package/dist/tools/sungen-auth/login-flow.js +107 -0
  98. package/dist/tools/sungen-auth/login-flow.js.map +1 -0
  99. package/dist/tools/sungen-auth/loopback-server.d.ts +20 -0
  100. package/dist/tools/sungen-auth/loopback-server.d.ts.map +1 -0
  101. package/dist/tools/sungen-auth/loopback-server.js +132 -0
  102. package/dist/tools/sungen-auth/loopback-server.js.map +1 -0
  103. package/dist/tools/sungen-auth/open-browser.d.ts +10 -0
  104. package/dist/tools/sungen-auth/open-browser.d.ts.map +1 -0
  105. package/dist/tools/sungen-auth/open-browser.js +43 -0
  106. package/dist/tools/sungen-auth/open-browser.js.map +1 -0
  107. package/dist/tools/sungen-auth/pkce.d.ts +13 -0
  108. package/dist/tools/sungen-auth/pkce.d.ts.map +1 -0
  109. package/dist/tools/sungen-auth/pkce.js +27 -0
  110. package/dist/tools/sungen-auth/pkce.js.map +1 -0
  111. package/dist/tools/sungen-auth/sungen-auth-types.d.ts +41 -0
  112. package/dist/tools/sungen-auth/sungen-auth-types.d.ts.map +1 -0
  113. package/dist/tools/sungen-auth/sungen-auth-types.js +7 -0
  114. package/dist/tools/sungen-auth/sungen-auth-types.js.map +1 -0
  115. package/package.json +3 -3
  116. package/src/cli/commands/feedback.ts +123 -51
  117. package/src/cli/commands/login.ts +75 -0
  118. package/src/cli/commands/qa-feedback.ts +77 -0
  119. package/src/cli/index.ts +4 -0
  120. package/src/dashboard/snapshot-builder.ts +100 -27
  121. package/src/dashboard/templates/index.html +23 -23
  122. package/src/dashboard/types.ts +5 -1
  123. package/src/exporters/csv-exporter.ts +10 -4
  124. package/src/exporters/feature-parser.ts +7 -2
  125. package/src/exporters/json-exporter.ts +11 -3
  126. package/src/exporters/playwright-report-parser.ts +50 -11
  127. package/src/exporters/scenario-merger.ts +49 -3
  128. package/src/exporters/spec-parser.ts +1 -1
  129. package/src/exporters/test-data-resolver.ts +13 -0
  130. package/src/exporters/types.ts +6 -0
  131. package/src/exporters/xlsx-exporter.ts +155 -49
  132. package/src/generators/gherkin-parser/index.ts +44 -1
  133. package/src/generators/test-generator/adapters/appium/templates/steps/actions/check-action.hbs +29 -8
  134. package/src/generators/test-generator/adapters/appium/templates/steps/actions/scroll-action.hbs +21 -17
  135. package/src/generators/test-generator/adapters/appium/templates/steps/actions/toggle-action.hbs +3 -0
  136. package/src/generators/test-generator/adapters/appium/templates/steps/actions/uncheck-action.hbs +26 -6
  137. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/is-hidden-assertion.hbs +3 -1
  138. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-assertion.hbs +14 -0
  139. package/src/generators/test-generator/adapters/appium/templates/steps/gestures/tap-top-action.hbs +3 -0
  140. package/src/generators/test-generator/adapters/appium/templates/steps/partials/locator.hbs +9 -2
  141. package/src/generators/test-generator/adapters/appium/templates/test-file.hbs +115 -0
  142. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row.hbs +1 -1
  143. package/src/harness/script-check.ts +10 -5
  144. package/src/orchestrator/ai-skills/writer.ts +39 -4
  145. package/src/orchestrator/templates/ai-src/commands/add-flow.md +52 -5
  146. package/src/orchestrator/templates/ai-src/commands/create-test.md +7 -0
  147. package/src/orchestrator/templates/ai-src/commands/feedback.md +6 -6
  148. package/src/orchestrator/templates/ai-src/commands/locale.md +26 -4
  149. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +5 -4
  150. package/src/orchestrator/templates/wdio.conf.ts +39 -3
  151. package/src/tools/sungen-api/edit-in-editor.ts +48 -0
  152. package/src/tools/sungen-api/feedback-client.ts +62 -0
  153. package/src/tools/sungen-api/read-stdin.ts +15 -0
  154. package/src/tools/sungen-auth/api-base-url.ts +23 -0
  155. package/src/tools/sungen-auth/credentials-store.ts +57 -0
  156. package/src/tools/sungen-auth/login-flow.ts +135 -0
  157. package/src/tools/sungen-auth/loopback-server.ts +113 -0
  158. package/src/tools/sungen-auth/open-browser.ts +42 -0
  159. package/src/tools/sungen-auth/pkce.ts +27 -0
  160. package/src/tools/sungen-auth/sungen-auth-types.ts +45 -0
@@ -1,62 +1,134 @@
1
+ /**
2
+ * `sungen feedback "<content>"` — submit PRODUCT feedback to the remote backend.
3
+ *
4
+ * One markdown record per call. Content comes from the positional arg, piped
5
+ * stdin, or — when run bare in a TTY — an $EDITOR session. Login-gated:
6
+ * missing/expired token prompts an interactive login. The author (created_by)
7
+ * is NEVER sent; the backend stamps it from the bearer token.
8
+ *
9
+ * NOTE: distinct from `sungen qa-feedback` (commands/qa-feedback.ts) — that writes
10
+ * the LOCAL QA-feedback log and is what the /sungen:feedback slash command calls.
11
+ */
12
+
1
13
  import { Command } from 'commander';
2
- import { recordFeedback, summarize, FeedbackType, FeedbackDecision } from '../../harness/feedback';
14
+ import * as readline from 'readline';
15
+ import { loadCredentials, isExpired } from '../../tools/sungen-auth/credentials-store';
16
+ import { resolveApiBaseUrl } from '../../tools/sungen-auth/api-base-url';
17
+ import { performLogin } from './login';
18
+ import { submitFeedback, AuthExpiredError, FeedbackPayload } from '../../tools/sungen-api/feedback-client';
19
+ import { readStdin } from '../../tools/sungen-api/read-stdin';
20
+ import { composeInEditor } from '../../tools/sungen-api/edit-in-editor';
3
21
 
4
- const TYPES = ['test-design', 'product', 'other'];
5
- const DECISIONS = ['accept', 'reject', 'edit', 'add', 'none'];
22
+ const CATEGORIES = ['quality', 'feature_request', 'bug', 'other'];
23
+ const QA_WORDS = ['record', 'list'];
24
+ const MAX_CONTENT_LEN = 10_000;
6
25
 
7
- export function registerFeedbackCommand(program: Command): void {
8
- const fb = program
9
- .command('feedback')
10
- .description('Local-first QA feedback (test-design knowledge + product telemetry). Synced later when a server exists.');
26
+ // CLI version sungen_version; same source as cli/index.ts so it never drifts.
27
+ const { version: SUNGEN_VERSION } = require('../../../package.json') as { version: string };
11
28
 
12
- fb
13
- .command('record')
14
- .description('Record a feedback entry')
15
- .requiredOption('--message <text>', 'The feedback')
16
- .option('--type <t>', `Feedback type (${TYPES.join('|')})`, 'test-design')
17
- .option('-s, --screen <name>', 'Screen/flow the feedback relates to')
18
- .option('--target <ref>', 'What it is about (viewpoint id / scenario / command / artifact)')
19
- .option('--decision <d>', `QA decision (${DECISIONS.join('|')})`, 'none')
20
- .option('--reason <text>', 'Why')
21
- .option('--source <who>', 'Who gave the feedback', 'qa')
22
- .action((o) => {
23
- try {
24
- if (!TYPES.includes(o.type)) throw new Error(`--type must be one of ${TYPES.join(', ')}`);
25
- if (!DECISIONS.includes(o.decision)) throw new Error(`--decision must be one of ${DECISIONS.join(', ')}`);
26
- const p = recordFeedback({
27
- type: o.type as FeedbackType,
28
- screen: o.screen,
29
- target: o.target,
30
- decision: o.decision as FeedbackDecision,
31
- message: o.message,
32
- reason: o.reason,
33
- source: o.source,
34
- });
35
- console.log(`✓ feedback recorded (${o.type}${o.screen ? ' · ' + o.screen : ''}) → ${p}`);
36
- } catch (e) {
37
- console.error('Error:', e instanceof Error ? e.message : e);
38
- process.exit(1);
39
- }
29
+ /** Ask a yes/no question on the terminal; defaults to "no" on empty input. */
30
+ function promptYesNo(question: string): Promise<boolean> {
31
+ return new Promise((resolve) => {
32
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
33
+ rl.question(question, (answer) => {
34
+ rl.close();
35
+ resolve(/^y(es)?$/i.test(answer.trim()));
40
36
  });
37
+ });
38
+ }
39
+
40
+ /** Resolve feedback content from positional arg or piped stdin. */
41
+ async function resolveContent(positional?: string): Promise<string | null> {
42
+ if (positional && positional.trim()) return positional;
43
+ const piped = await readStdin();
44
+ if (piped && piped.trim()) return piped;
45
+ return null;
46
+ }
47
+
48
+ /** Ensure we have a valid token; prompt an interactive login if not. Returns creds or null if the user declines. */
49
+ async function ensureLogin(apiBaseUrl: string): Promise<{ token: string; api_base_url: string } | null> {
50
+ const creds = loadCredentials();
51
+ if (creds && !isExpired(creds)) return creds;
52
+
53
+ const reason = creds ? 'Your session has expired.' : 'You are not logged in.';
54
+ const yes = await promptYesNo(`${reason} Log in now? (y/n) `);
55
+ if (!yes) return null;
56
+ return performLogin(apiBaseUrl);
57
+ }
41
58
 
42
- fb
43
- .command('list')
44
- .description('List / summarise recorded feedback')
45
- .option('-s, --screen <name>', 'Filter by screen')
46
- .option('--type <t>', 'Filter by type')
47
- .option('--json', 'Output raw JSON')
48
- .action((o) => {
59
+ export function registerFeedbackCommand(program: Command): void {
60
+ program
61
+ .command('feedback [content]')
62
+ .description('Send product feedback about sungen (one record; pass as an argument, pipe via stdin, or run with no argument to compose in $EDITOR)')
63
+ .option('--category <c>', `Feedback category (${CATEGORIES.join('|')})`, 'other')
64
+ .option('--api-base-url <url>', 'Backend base URL (overrides SUNGEN_API_BASE_URL + stored value)')
65
+ .action(async (content: string | undefined, options: { category: string; apiBaseUrl?: string }) => {
49
66
  try {
50
- const s = summarize({ screen: o.screen, type: o.type });
51
- if (o.json) { console.log(JSON.stringify(s, null, 2)); return; }
52
- console.log(`\n━━━ Feedback (${s.total}) ━━━`);
53
- console.log(` by type: ${Object.entries(s.byType).map(([k, v]) => `${k}=${v}`).join(' ') || '—'}`);
54
- console.log(` by decision: ${Object.entries(s.byDecision).map(([k, v]) => `${k}=${v}`).join(' ') || '—'}\n`);
55
- for (const e of s.entries.slice(-20)) {
56
- console.log(` [${e.type}${e.decision && e.decision !== 'none' ? '/' + e.decision : ''}] ${e.screen ? e.screen + ' · ' : ''}${e.target ? e.target + ' — ' : ''}${e.message}`);
57
- if (e.reason) console.log(` reason: ${e.reason}`);
67
+ // Footgun guard: a bare `record`/`list` with no stdin is almost certainly
68
+ // someone reaching for the moved QA primitive, not real feedback text.
69
+ if (content && QA_WORDS.includes(content.trim()) && process.stdin.isTTY) {
70
+ console.error(
71
+ `The local QA log moved to \`sungen qa-feedback ${content.trim()}\`.\n` +
72
+ `To send "${content.trim()}" as product feedback, pipe it via stdin: echo "${content.trim()}" | sungen feedback`,
73
+ );
74
+ process.exit(1);
75
+ }
76
+
77
+ let text = await resolveContent(content);
78
+ if (!text) {
79
+ if (process.stdin.isTTY) {
80
+ // No arg and interactive: open the editor (like `git commit`).
81
+ text = composeInEditor();
82
+ if (!text) {
83
+ console.error('Aborting — no feedback provided (empty message).');
84
+ process.exit(1);
85
+ }
86
+ } else {
87
+ // Piped but empty.
88
+ console.error('Provide feedback text as an argument or pipe it via stdin.');
89
+ process.exit(1);
90
+ }
91
+ }
92
+ if (text.length > MAX_CONTENT_LEN) {
93
+ console.error(`Feedback is too long (${text.length} chars; max ${MAX_CONTENT_LEN}).`);
94
+ process.exit(1);
95
+ }
96
+
97
+ if (!CATEGORIES.includes(options.category)) {
98
+ console.error(`--category must be one of ${CATEGORIES.join(', ')}`);
99
+ process.exit(1);
100
+ }
101
+
102
+ const apiBaseUrl = resolveApiBaseUrl(options.apiBaseUrl);
103
+ const creds = await ensureLogin(apiBaseUrl);
104
+ if (!creds) {
105
+ console.error('Run `sungen login` first, then re-send your feedback.');
106
+ process.exit(1);
107
+ }
108
+
109
+ const payload: FeedbackPayload = {
110
+ content: text,
111
+ category: options.category,
112
+ sungen_version: SUNGEN_VERSION,
113
+ os: process.platform,
114
+ source: 'cli',
115
+ };
116
+
117
+ try {
118
+ const { id } = await submitFeedback(creds.api_base_url, creds.token, payload);
119
+ console.log(`Feedback submitted (id=${id}).`);
120
+ } catch (err) {
121
+ if (err instanceof AuthExpiredError) {
122
+ // Token died between load and POST — re-login once, then retry.
123
+ const yes = await promptYesNo('Your session expired. Log in again and retry? (y/n) ');
124
+ if (!yes) { console.error('Aborted.'); process.exit(1); }
125
+ const fresh = await performLogin(apiBaseUrl);
126
+ const { id } = await submitFeedback(fresh.api_base_url, fresh.token, payload);
127
+ console.log(`Feedback submitted (id=${id}).`);
128
+ } else {
129
+ throw err;
130
+ }
58
131
  }
59
- console.log('');
60
132
  } catch (e) {
61
133
  console.error('Error:', e instanceof Error ? e.message : e);
62
134
  process.exit(1);
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `sungen login` / `sungen logout`.
3
+ *
4
+ * login — RFC 8252 native-app login (PKCE-S256 + loopback + one-time code)
5
+ * against the FastSchema backend; caches the token at
6
+ * ~/.sungen/credentials.json (0600).
7
+ * logout — clears that file.
8
+ *
9
+ * Mirrors the structure of `commands/figma.ts` (commander + scrubbed errors).
10
+ */
11
+
12
+ import { Command } from 'commander';
13
+ import { generatePkce, randomCorrelation } from '../../tools/sungen-auth/pkce';
14
+ import { startLoopback } from '../../tools/sungen-auth/loopback-server';
15
+ import { runLoginFlow } from '../../tools/sungen-auth/login-flow';
16
+ import { saveCredentials, clearCredentials, credentialsPath } from '../../tools/sungen-auth/credentials-store';
17
+ import { openBrowser } from '../../tools/sungen-auth/open-browser';
18
+ import { resolveApiBaseUrl } from '../../tools/sungen-auth/api-base-url';
19
+ import type { Credentials } from '../../tools/sungen-auth/sungen-auth-types';
20
+
21
+ /**
22
+ * Run the full login flow against `apiBaseUrl`, persist the token, and return
23
+ * the stored credentials. Reused by `sungen feedback` for on-demand re-auth.
24
+ */
25
+ export async function performLogin(apiBaseUrl: string): Promise<Credentials> {
26
+ const pkce = generatePkce();
27
+ const correlation = randomCorrelation();
28
+ const loopback = await startLoopback();
29
+ try {
30
+ const result = await runLoginFlow({
31
+ apiBaseUrl,
32
+ pkce,
33
+ correlation,
34
+ redirectUri: loopback.redirectUri,
35
+ openBrowser,
36
+ waitForCode: (corr, timeoutMs) => loopback.waitForCode(corr, timeoutMs),
37
+ });
38
+ const creds: Credentials = {
39
+ token: result.token,
40
+ email: result.email,
41
+ api_base_url: apiBaseUrl,
42
+ expires_at: result.expires_at,
43
+ };
44
+ saveCredentials(creds);
45
+ return creds;
46
+ } finally {
47
+ loopback.close();
48
+ }
49
+ }
50
+
51
+ export function registerLoginCommand(program: Command): void {
52
+ program
53
+ .command('login')
54
+ .description('Log in to the sungen feedback backend (opens your browser)')
55
+ .option('--api-base-url <url>', 'Backend base URL (overrides SUNGEN_API_BASE_URL + stored value)')
56
+ .action(async (options: { apiBaseUrl?: string }) => {
57
+ const apiBaseUrl = resolveApiBaseUrl(options.apiBaseUrl);
58
+ try {
59
+ const creds = await performLogin(apiBaseUrl);
60
+ console.log(`Logged in as ${creds.email}.`);
61
+ console.log(`Token saved to ${credentialsPath()} (mode 0600).`);
62
+ } catch (err: any) {
63
+ console.error(`Login failed: ${err?.message ?? err}`);
64
+ process.exit(1);
65
+ }
66
+ });
67
+
68
+ program
69
+ .command('logout')
70
+ .description('Remove the cached sungen login token')
71
+ .action(() => {
72
+ clearCredentials();
73
+ console.log(`Logged out. Removed ${credentialsPath()} (if present).`);
74
+ });
75
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `sungen qa-feedback` — the LOCAL QA-feedback log (test-design knowledge +
3
+ * product telemetry), persisted to .sungen/feedback/feedback.jsonl.
4
+ *
5
+ * This is the persistence primitive behind the `/sungen:feedback` slash command
6
+ * (which auto-attaches screen/viewpoint/decision context and then calls
7
+ * `sungen qa-feedback record`). It is rarely typed by hand.
8
+ *
9
+ * NOTE: distinct from `sungen feedback` (see commands/feedback.ts) — that command
10
+ * submits PRODUCT feedback to the remote backend. This one only writes locally.
11
+ */
12
+
13
+ import { Command } from 'commander';
14
+ import { recordFeedback, summarize, FeedbackType, FeedbackDecision } from '../../harness/feedback';
15
+
16
+ const TYPES = ['test-design', 'product', 'other'];
17
+ const DECISIONS = ['accept', 'reject', 'edit', 'add', 'none'];
18
+
19
+ export function registerQaFeedbackCommand(program: Command): void {
20
+ const fb = program
21
+ .command('qa-feedback')
22
+ .description('Local-first QA feedback log (test-design knowledge + product telemetry). Primarily invoked by the /sungen:feedback slash command.');
23
+
24
+ fb
25
+ .command('record')
26
+ .description('Record a feedback entry')
27
+ .requiredOption('--message <text>', 'The feedback')
28
+ .option('--type <t>', `Feedback type (${TYPES.join('|')})`, 'test-design')
29
+ .option('-s, --screen <name>', 'Screen/flow the feedback relates to')
30
+ .option('--target <ref>', 'What it is about (viewpoint id / scenario / command / artifact)')
31
+ .option('--decision <d>', `QA decision (${DECISIONS.join('|')})`, 'none')
32
+ .option('--reason <text>', 'Why')
33
+ .option('--source <who>', 'Who gave the feedback', 'qa')
34
+ .action((o) => {
35
+ try {
36
+ if (!TYPES.includes(o.type)) throw new Error(`--type must be one of ${TYPES.join(', ')}`);
37
+ if (!DECISIONS.includes(o.decision)) throw new Error(`--decision must be one of ${DECISIONS.join(', ')}`);
38
+ const p = recordFeedback({
39
+ type: o.type as FeedbackType,
40
+ screen: o.screen,
41
+ target: o.target,
42
+ decision: o.decision as FeedbackDecision,
43
+ message: o.message,
44
+ reason: o.reason,
45
+ source: o.source,
46
+ });
47
+ console.log(`✓ feedback recorded (${o.type}${o.screen ? ' · ' + o.screen : ''}) → ${p}`);
48
+ } catch (e) {
49
+ console.error('Error:', e instanceof Error ? e.message : e);
50
+ process.exit(1);
51
+ }
52
+ });
53
+
54
+ fb
55
+ .command('list')
56
+ .description('List / summarise recorded feedback')
57
+ .option('-s, --screen <name>', 'Filter by screen')
58
+ .option('--type <t>', 'Filter by type')
59
+ .option('--json', 'Output raw JSON')
60
+ .action((o) => {
61
+ try {
62
+ const s = summarize({ screen: o.screen, type: o.type });
63
+ if (o.json) { console.log(JSON.stringify(s, null, 2)); return; }
64
+ console.log(`\n━━━ Feedback (${s.total}) ━━━`);
65
+ console.log(` by type: ${Object.entries(s.byType).map(([k, v]) => `${k}=${v}`).join(' ') || '—'}`);
66
+ console.log(` by decision: ${Object.entries(s.byDecision).map(([k, v]) => `${k}=${v}`).join(' ') || '—'}\n`);
67
+ for (const e of s.entries.slice(-20)) {
68
+ console.log(` [${e.type}${e.decision && e.decision !== 'none' ? '/' + e.decision : ''}] ${e.screen ? e.screen + ' · ' : ''}${e.target ? e.target + ' — ' : ''}${e.message}`);
69
+ if (e.reason) console.log(` reason: ${e.reason}`);
70
+ }
71
+ console.log('');
72
+ } catch (e) {
73
+ console.error('Error:', e instanceof Error ? e.message : e);
74
+ process.exit(1);
75
+ }
76
+ });
77
+ }
package/src/cli/index.ts CHANGED
@@ -23,6 +23,7 @@ import { registerEvalCommand } from './commands/eval';
23
23
  import { registerManifestCommand } from './commands/manifest';
24
24
  import { registerLedgerCommand } from './commands/ledger';
25
25
  import { registerFeedbackCommand } from './commands/feedback';
26
+ import { registerQaFeedbackCommand } from './commands/qa-feedback';
26
27
  import { registerScriptCheckCommand } from './commands/script-check';
27
28
  import { registerTraceCommand } from './commands/trace';
28
29
  import { registerChallengeCommand } from './commands/challenge';
@@ -31,6 +32,7 @@ import { registerCapabilityCommand } from './commands/capability';
31
32
  import { registerFlowCheckCommand } from './commands/flow-check';
32
33
  import { registerContextCommand } from './commands/context';
33
34
  import { registerRepairCommand } from './commands/repair';
35
+ import { registerLoginCommand } from './commands/login';
34
36
  import { capabilityRegistry } from '../capabilities/registry';
35
37
  import { discoverAndRegisterCapabilities } from '../capabilities/discover';
36
38
 
@@ -66,6 +68,7 @@ async function main() {
66
68
  registerManifestCommand(program);
67
69
  registerLedgerCommand(program);
68
70
  registerFeedbackCommand(program);
71
+ registerQaFeedbackCommand(program);
69
72
  registerScriptCheckCommand(program);
70
73
  registerTraceCommand(program);
71
74
  registerChallengeCommand(program);
@@ -76,6 +79,7 @@ async function main() {
76
79
  registerRepairCommand(program);
77
80
  registerIngestCommand(program);
78
81
  registerEvalCommand(program);
82
+ registerLoginCommand(program);
79
83
 
80
84
  // Capability-contributed CLI commands (Capability SPI): drivers own their authoring commands
81
85
  // (e.g. @sungen/driver-api → `sungen api import`). Discover, then register each.
@@ -85,6 +85,23 @@ export function buildDashboardSnapshot(options: BuildSnapshotOptions): Dashboard
85
85
  // Per-target build
86
86
  // ----------------------------------------------------------------------------
87
87
 
88
+ /**
89
+ * True when this is a mobile project (`platform: mobile` in qa/capabilities.yaml).
90
+ * Mobile projects run per-OS (android/ios) — the dashboard surfaces those on a
91
+ * distinct platform axis (one FeatureSnapshot per OS) rather than as locale tabs.
92
+ * Mirrors `isMobileProject` in src/cli/commands/delivery.ts.
93
+ */
94
+ function isMobileProject(cwd: string): boolean {
95
+ try {
96
+ const p = path.join(cwd, 'qa', 'capabilities.yaml');
97
+ if (!fs.existsSync(p)) return false;
98
+ const parsed = parseYaml(fs.readFileSync(p, 'utf-8')) as { platform?: string } | null;
99
+ return parsed?.platform === 'mobile';
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
88
105
  function buildOneScreen(
89
106
  cwd: string,
90
107
  target: DashboardTarget,
@@ -105,28 +122,46 @@ function buildOneScreen(
105
122
  const specMdFile = path.join(base, 'requirements', 'spec.md');
106
123
  const specLink = fs.existsSync(specMdFile) ? path.relative(cwd, specMdFile) : undefined;
107
124
 
125
+ const mobile = isMobileProject(cwd);
108
126
  const features: FeatureSnapshot[] = [];
109
127
  for (const featureBaseName of featureBaseNames) {
110
- const fs2 = buildOneFeature(cwd, target, featureBaseName, env, specLink);
111
- if (fs2) features.push(fs2);
128
+ features.push(...buildOneFeature(cwd, target, featureBaseName, env, specLink, mobile));
112
129
  }
113
130
  if (features.length === 0) return null;
114
131
 
115
132
  // Flat scenarios across all features (each scenario already carries its
116
133
  // own `featureBaseName`, so consumers that want to group can do so cheaply).
134
+ // For mobile, per-OS features flatten here so the Compare view can key each
135
+ // scenario by (tcId, platform) via its OS-precise testEnvironment.
117
136
  const scenarios = features.flatMap((f) => f.scenarios);
118
137
  const label = target.isFlow ? `flow/${target.name}` : target.name;
119
- const primary = features[0];
138
+
139
+ // Screen-level platform axis: 'mobile' when any feature is OS-tagged; the
140
+ // union of OSes that actually produced a result file this run.
141
+ const execSet = new Set<'android' | 'ios'>();
142
+ let anyPlatform = false;
143
+ for (const f of features) {
144
+ if (f.platform) anyPlatform = true;
145
+ for (const p of f.executedPlatforms ?? []) execSet.add(p);
146
+ }
147
+ const executedPlatforms = execSet.size > 0
148
+ ? (['android', 'ios'] as const).filter((p) => execSet.has(p))
149
+ : undefined;
150
+ // Representative feature for the back-compat top-level fields: prefer Android
151
+ // (deterministic, matches delivery's summary-row preference).
152
+ const primary = features.find((f) => f.platform === 'android') ?? features[0];
120
153
 
121
154
  return {
122
155
  name: label,
123
156
  isFlow: target.isFlow,
157
+ platform: anyPlatform ? 'mobile' : undefined,
124
158
  featureName: primary.featureName,
125
159
  featurePath: primary.featurePath,
126
160
  specLink,
127
161
  summary: rollupScreenSummary(features),
128
162
  scenarios,
129
163
  features,
164
+ executedPlatforms,
130
165
  };
131
166
  }
132
167
 
@@ -141,14 +176,15 @@ function buildOneFeature(
141
176
  featureBaseName: string,
142
177
  env: EnvironmentInfo,
143
178
  specLink: string | undefined,
144
- ): FeatureSnapshot | null {
179
+ mobile: boolean,
180
+ ): FeatureSnapshot[] {
145
181
  const base = qaDir(cwd, target);
146
182
  const genBase = generatedDir(cwd, target);
147
183
  const featureFile = path.join(base, 'features', `${featureBaseName}.feature`);
148
184
  const testDataFile = path.join(base, 'test-data', `${featureBaseName}.yaml`);
149
185
  const specFile = path.join(genBase, `${featureBaseName}.spec.ts`);
150
186
 
151
- if (!fs.existsSync(featureFile)) return null;
187
+ if (!fs.existsSync(featureFile)) return [];
152
188
 
153
189
  const feature = parseFeatureMetadata(featureFile);
154
190
  const spec = fs.existsSync(specFile)
@@ -168,27 +204,16 @@ function buildOneFeature(
168
204
  // Discover locale variants by scanning <feature>-test-result*.json files.
169
205
  // Always includes base ('') as the first entry, even if its results file
170
206
  // is missing — UI still needs to render the feature.
171
- // NOTE (mobile): for a `platform: mobile` project the "locale" variants are
172
- // actually the per-OS runs (android / ios) — the dashboard reuses the locale
173
- // switcher to surface them as tabs. The env column per tab is OS-precise (see
174
- // resolvePlatformAppId below), but the top-level featurePath (base variant)
175
- // defaults to the Android id. Delivery has a dedicated per-OS path; aligning the
176
- // dashboard onto a distinct "platform" axis is a possible follow-up.
177
207
  const variants = discoverLocaleVariants(genBase, featureBaseName);
178
208
 
179
- // Build per-locale ScreenSnapshot, then collect into FeatureSnapshot.locales[].
180
- // Base variant is also used to populate the top-level (`scenarios`, `summary`)
181
- // for back-compat with consumers that don't read the locales array.
182
- const locales: LocaleSnapshot[] = [];
183
- let baseBuilt: { scenarios: ScenarioSnapshot[]; summary: ScreenSummaryStats; featureName: string; featurePath?: string; specLink?: string } | null = null;
184
-
185
- for (const variant of variants) {
209
+ // Build one per-variant ScreenSnapshot (test-data overlay + results + OS-precise
210
+ // env column). Reused by both the web/i18n (locale-axis) and mobile (platform-axis) paths.
211
+ const buildVariant = (variant: FeatureLocaleVariant) => {
186
212
  const testData = fs.existsSync(testDataFile)
187
213
  ? loadTestData(testDataFile, variant.locale || null)
188
214
  : {};
189
215
  const results = variant.resultsPath ? loadPlaywrightReport(variant.resultsPath) : null;
190
-
191
- const built = buildScreenSnapshot({
216
+ return buildScreenSnapshot({
192
217
  screen: featureBaseName,
193
218
  isFlow: target.isFlow,
194
219
  featureName: feature.featureName,
@@ -203,17 +228,65 @@ function buildOneFeature(
203
228
  featureBaseName,
204
229
  selectorKeyMap,
205
230
  });
231
+ };
232
+ type Built = ReturnType<typeof buildVariant>;
233
+
234
+ // -- Mobile (platform axis): the per-OS runs (android / ios) are their own
235
+ // FeatureSnapshot entries — one per OS, `platform` tagged — NOT locale tabs.
236
+ // Locale overlays for a given OS (e.g. `android.ja`) nest inside that OS's
237
+ // entry as `locales[]`. This mirrors the delivery per-OS deliverables. --
238
+ if (mobile) {
239
+ const osOf = (loc: string): 'android' | 'ios' | null =>
240
+ /^android(\.|$)/.test(loc) ? 'android' : /^ios(\.|$)/.test(loc) ? 'ios' : null;
241
+ const osVariants = variants.filter((v) => osOf(v.locale));
242
+ if (osVariants.length > 0) {
243
+ const executedPlatforms = (['android', 'ios'] as const).filter((os) =>
244
+ osVariants.some((v) => osOf(v.locale) === os && v.resultsPath)
245
+ );
246
+ const feats: FeatureSnapshot[] = [];
247
+ for (const os of ['android', 'ios'] as const) {
248
+ const group = osVariants.filter((v) => osOf(v.locale) === os);
249
+ if (group.length === 0) continue;
250
+ const localeSnaps: LocaleSnapshot[] = [];
251
+ let primaryBuilt: Built | null = null;
252
+ for (const v of group) {
253
+ const built = buildVariant(v);
254
+ localeSnaps.push({ locale: v.locale, displayCode: v.displayCode, summary: built.summary, scenarios: built.scenarios });
255
+ // Representative = the bare OS run (locale === os); else the first overlay.
256
+ if (v.locale === os || !primaryBuilt) primaryBuilt = built;
257
+ }
258
+ if (!primaryBuilt) continue;
259
+ feats.push({
260
+ featureBaseName,
261
+ featureName: primaryBuilt.featureName,
262
+ featurePath: primaryBuilt.featurePath,
263
+ specLink: primaryBuilt.specLink,
264
+ summary: primaryBuilt.summary,
265
+ scenarios: primaryBuilt.scenarios,
266
+ platform: os,
267
+ executedPlatforms,
268
+ locales: localeSnaps.length >= 2 ? localeSnaps : undefined,
269
+ });
270
+ }
271
+ if (feats.length > 0) return feats;
272
+ }
273
+ // Mobile project but no per-OS result files yet → fall through to the base
274
+ // (locale-axis) path so the test CASES still render.
275
+ }
276
+
277
+ // -- Web / i18n (locale axis): one FeatureSnapshot; variants become locales[]. --
278
+ const locales: LocaleSnapshot[] = [];
279
+ let baseBuilt: Built | { scenarios: ScenarioSnapshot[]; summary: ScreenSummaryStats; featureName: string; featurePath?: string; specLink?: string } | null = null;
206
280
 
281
+ for (const variant of variants) {
282
+ const built = buildVariant(variant);
207
283
  locales.push({
208
284
  locale: variant.locale,
209
285
  displayCode: variant.displayCode,
210
286
  summary: built.summary,
211
287
  scenarios: built.scenarios,
212
288
  });
213
-
214
- if (variant.locale === '') {
215
- baseBuilt = built;
216
- }
289
+ if (variant.locale === '') baseBuilt = built;
217
290
  }
218
291
 
219
292
  // Guarantee a base entry — every feature in the dashboard must have
@@ -227,9 +300,9 @@ function buildOneFeature(
227
300
  specLink,
228
301
  };
229
302
  }
230
- if (!baseBuilt) return null;
303
+ if (!baseBuilt) return [];
231
304
 
232
- return {
305
+ return [{
233
306
  featureBaseName,
234
307
  featureName: baseBuilt.featureName,
235
308
  featurePath: baseBuilt.featurePath,
@@ -239,7 +312,7 @@ function buildOneFeature(
239
312
  // Only expose `locales` array when 2+ variants exist — single-locale
240
313
  // features stay slimmer and UI can skip the locale tab UI entirely.
241
314
  locales: locales.length >= 2 ? locales : undefined,
242
- };
315
+ }];
243
316
  }
244
317
 
245
318
  /**