@sun-asterisk/sungen 3.2.3-beta.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.
- package/dist/cli/commands/feedback.d.ts +11 -0
- package/dist/cli/commands/feedback.d.ts.map +1 -1
- package/dist/cli/commands/feedback.js +154 -57
- package/dist/cli/commands/feedback.js.map +1 -1
- package/dist/cli/commands/login.d.ts +19 -0
- package/dist/cli/commands/login.d.ts.map +1 -0
- package/dist/cli/commands/login.js +76 -0
- package/dist/cli/commands/login.js.map +1 -0
- package/dist/cli/commands/qa-feedback.d.ts +14 -0
- package/dist/cli/commands/qa-feedback.d.ts.map +1 -0
- package/dist/cli/commands/qa-feedback.js +83 -0
- package/dist/cli/commands/qa-feedback.js.map +1 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
- package/dist/dashboard/snapshot-builder.js +108 -33
- package/dist/dashboard/snapshot-builder.js.map +1 -1
- package/dist/dashboard/templates/index.html +23 -23
- package/dist/dashboard/types.d.ts +4 -0
- package/dist/dashboard/types.d.ts.map +1 -1
- package/dist/exporters/csv-exporter.d.ts.map +1 -1
- package/dist/exporters/csv-exporter.js +11 -4
- package/dist/exporters/csv-exporter.js.map +1 -1
- package/dist/exporters/feature-parser.d.ts +4 -0
- package/dist/exporters/feature-parser.d.ts.map +1 -1
- package/dist/exporters/feature-parser.js +7 -2
- package/dist/exporters/feature-parser.js.map +1 -1
- package/dist/exporters/json-exporter.d.ts.map +1 -1
- package/dist/exporters/json-exporter.js +11 -3
- package/dist/exporters/json-exporter.js.map +1 -1
- package/dist/exporters/playwright-report-parser.d.ts +21 -2
- package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
- package/dist/exporters/playwright-report-parser.js +52 -12
- package/dist/exporters/playwright-report-parser.js.map +1 -1
- package/dist/exporters/scenario-merger.d.ts.map +1 -1
- package/dist/exporters/scenario-merger.js +50 -2
- package/dist/exporters/scenario-merger.js.map +1 -1
- package/dist/exporters/spec-parser.js +1 -1
- package/dist/exporters/spec-parser.js.map +1 -1
- package/dist/exporters/test-data-resolver.d.ts +7 -0
- package/dist/exporters/test-data-resolver.d.ts.map +1 -1
- package/dist/exporters/test-data-resolver.js +13 -0
- package/dist/exporters/test-data-resolver.js.map +1 -1
- package/dist/exporters/types.d.ts +6 -0
- package/dist/exporters/types.d.ts.map +1 -1
- package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
- package/dist/exporters/xlsx-exporter.js +131 -42
- package/dist/exporters/xlsx-exporter.js.map +1 -1
- package/dist/generators/gherkin-parser/index.d.ts +15 -0
- package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
- package/dist/generators/gherkin-parser/index.js +33 -1
- package/dist/generators/gherkin-parser/index.js.map +1 -1
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/check-action.hbs +29 -8
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/scroll-action.hbs +21 -17
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/toggle-action.hbs +3 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/uncheck-action.hbs +26 -6
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/is-hidden-assertion.hbs +3 -1
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-assertion.hbs +14 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/gestures/tap-top-action.hbs +3 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/partials/locator.hbs +9 -2
- package/dist/generators/test-generator/adapters/appium/templates/test-file.hbs +115 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row.hbs +1 -1
- package/dist/harness/script-check.d.ts.map +1 -1
- package/dist/harness/script-check.js +10 -5
- package/dist/harness/script-check.js.map +1 -1
- package/dist/orchestrator/ai-skills/writer.d.ts.map +1 -1
- package/dist/orchestrator/ai-skills/writer.js +46 -4
- package/dist/orchestrator/ai-skills/writer.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/add-flow.md +52 -5
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +7 -0
- package/dist/orchestrator/templates/ai-src/commands/feedback.md +6 -6
- package/dist/orchestrator/templates/ai-src/commands/locale.md +26 -4
- package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +5 -4
- package/dist/orchestrator/templates/wdio.conf.ts +39 -3
- package/dist/tools/sungen-api/edit-in-editor.d.ts +15 -0
- package/dist/tools/sungen-api/edit-in-editor.d.ts.map +1 -0
- package/dist/tools/sungen-api/edit-in-editor.js +85 -0
- package/dist/tools/sungen-api/edit-in-editor.js.map +1 -0
- package/dist/tools/sungen-api/feedback-client.d.ts +24 -0
- package/dist/tools/sungen-api/feedback-client.d.ts.map +1 -0
- package/dist/tools/sungen-api/feedback-client.js +53 -0
- package/dist/tools/sungen-api/feedback-client.js.map +1 -0
- package/dist/tools/sungen-api/read-stdin.d.ts +6 -0
- package/dist/tools/sungen-api/read-stdin.d.ts.map +1 -0
- package/dist/tools/sungen-api/read-stdin.js +19 -0
- package/dist/tools/sungen-api/read-stdin.js.map +1 -0
- package/dist/tools/sungen-auth/api-base-url.d.ts +14 -0
- package/dist/tools/sungen-auth/api-base-url.d.ts.map +1 -0
- package/dist/tools/sungen-auth/api-base-url.js +28 -0
- package/dist/tools/sungen-auth/api-base-url.js.map +1 -0
- package/dist/tools/sungen-auth/credentials-store.d.ts +19 -0
- package/dist/tools/sungen-auth/credentials-store.d.ts.map +1 -0
- package/dist/tools/sungen-auth/credentials-store.js +94 -0
- package/dist/tools/sungen-auth/credentials-store.js.map +1 -0
- package/dist/tools/sungen-auth/login-flow.d.ts +33 -0
- package/dist/tools/sungen-auth/login-flow.d.ts.map +1 -0
- package/dist/tools/sungen-auth/login-flow.js +107 -0
- package/dist/tools/sungen-auth/login-flow.js.map +1 -0
- package/dist/tools/sungen-auth/loopback-server.d.ts +20 -0
- package/dist/tools/sungen-auth/loopback-server.d.ts.map +1 -0
- package/dist/tools/sungen-auth/loopback-server.js +132 -0
- package/dist/tools/sungen-auth/loopback-server.js.map +1 -0
- package/dist/tools/sungen-auth/open-browser.d.ts +10 -0
- package/dist/tools/sungen-auth/open-browser.d.ts.map +1 -0
- package/dist/tools/sungen-auth/open-browser.js +43 -0
- package/dist/tools/sungen-auth/open-browser.js.map +1 -0
- package/dist/tools/sungen-auth/pkce.d.ts +13 -0
- package/dist/tools/sungen-auth/pkce.d.ts.map +1 -0
- package/dist/tools/sungen-auth/pkce.js +27 -0
- package/dist/tools/sungen-auth/pkce.js.map +1 -0
- package/dist/tools/sungen-auth/sungen-auth-types.d.ts +41 -0
- package/dist/tools/sungen-auth/sungen-auth-types.d.ts.map +1 -0
- package/dist/tools/sungen-auth/sungen-auth-types.js +7 -0
- package/dist/tools/sungen-auth/sungen-auth-types.js.map +1 -0
- package/package.json +3 -3
- package/src/cli/commands/feedback.ts +123 -51
- package/src/cli/commands/login.ts +75 -0
- package/src/cli/commands/qa-feedback.ts +77 -0
- package/src/cli/index.ts +4 -0
- package/src/dashboard/snapshot-builder.ts +100 -27
- package/src/dashboard/templates/index.html +23 -23
- package/src/dashboard/types.ts +5 -1
- package/src/exporters/csv-exporter.ts +10 -4
- package/src/exporters/feature-parser.ts +7 -2
- package/src/exporters/json-exporter.ts +11 -3
- package/src/exporters/playwright-report-parser.ts +50 -11
- package/src/exporters/scenario-merger.ts +49 -3
- package/src/exporters/spec-parser.ts +1 -1
- package/src/exporters/test-data-resolver.ts +13 -0
- package/src/exporters/types.ts +6 -0
- package/src/exporters/xlsx-exporter.ts +155 -49
- package/src/generators/gherkin-parser/index.ts +44 -1
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/check-action.hbs +29 -8
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/scroll-action.hbs +21 -17
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/toggle-action.hbs +3 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/uncheck-action.hbs +26 -6
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/is-hidden-assertion.hbs +3 -1
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-assertion.hbs +14 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/gestures/tap-top-action.hbs +3 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/partials/locator.hbs +9 -2
- package/src/generators/test-generator/adapters/appium/templates/test-file.hbs +115 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row.hbs +1 -1
- package/src/harness/script-check.ts +10 -5
- package/src/orchestrator/ai-skills/writer.ts +39 -4
- package/src/orchestrator/templates/ai-src/commands/add-flow.md +52 -5
- package/src/orchestrator/templates/ai-src/commands/create-test.md +7 -0
- package/src/orchestrator/templates/ai-src/commands/feedback.md +6 -6
- package/src/orchestrator/templates/ai-src/commands/locale.md +26 -4
- package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +5 -4
- package/src/orchestrator/templates/wdio.conf.ts +39 -3
- package/src/tools/sungen-api/edit-in-editor.ts +48 -0
- package/src/tools/sungen-api/feedback-client.ts +62 -0
- package/src/tools/sungen-api/read-stdin.ts +15 -0
- package/src/tools/sungen-auth/api-base-url.ts +23 -0
- package/src/tools/sungen-auth/credentials-store.ts +57 -0
- package/src/tools/sungen-auth/login-flow.ts +135 -0
- package/src/tools/sungen-auth/loopback-server.ts +113 -0
- package/src/tools/sungen-auth/open-browser.ts +42 -0
- package/src/tools/sungen-auth/pkce.ts +27 -0
- 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
|
|
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
|
|
5
|
-
const
|
|
22
|
+
const CATEGORIES = ['quality', 'feature_request', 'bug', 'other'];
|
|
23
|
+
const QA_WORDS = ['record', 'list'];
|
|
24
|
+
const MAX_CONTENT_LEN = 10_000;
|
|
6
25
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
.
|
|
16
|
-
.
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
.
|
|
45
|
-
.
|
|
46
|
-
.option('--
|
|
47
|
-
.option('--
|
|
48
|
-
.action((
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
180
|
-
//
|
|
181
|
-
|
|
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
|
|
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
|
/**
|