@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.
- 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,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Bootstrap i18n for a screen/flow — audit selectors, detect locale switch mechanism via Playwright, generate test-data overlay so `sungen:run-test --env <locale>` works."
|
|
2
|
+
description: "Bootstrap i18n for a screen/flow — audit selectors, detect locale switch mechanism via Playwright (web) or device locale (mobile), generate test-data overlay so `sungen:run-test --env <locale>` works."
|
|
3
3
|
argument-hint: "<name> <locale> [--base-locale <code>] [--offline]"
|
|
4
4
|
order: 45
|
|
5
|
-
claude-tools: "Read, Grep, Bash, Glob, Edit, Write, AskUserQuestion, mcp__playwright__browser_navigate, mcp__playwright__browser_snapshot, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_wait_for, mcp__playwright__browser_evaluate, mcp__playwright__browser_click, mcp__playwright__browser_storage_state, mcp__playwright__browser_set_storage_state"
|
|
5
|
+
claude-tools: "Read, Grep, Bash, Glob, Edit, Write, AskUserQuestion, mcp__playwright__browser_navigate, mcp__playwright__browser_snapshot, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_wait_for, mcp__playwright__browser_evaluate, mcp__playwright__browser_click, mcp__playwright__browser_storage_state, mcp__playwright__browser_set_storage_state, mcp__appium-mcp__select_device, mcp__appium-mcp__appium_session_management, mcp__appium-mcp__appium_get_page_source, mcp__appium-mcp__appium_screenshot, mcp__appium-mcp__generate_locators"
|
|
6
6
|
copilot-tools: "[read, execute, edit, vscode/askQuestions, playwright/*]"
|
|
7
7
|
codex-trigger: "Run when the user asks to bootstrap i18n/localization for a screen. Runs before run-test --env <locale>."
|
|
8
8
|
---
|
|
@@ -33,6 +33,27 @@ Reject if name == locale (common typo).
|
|
|
33
33
|
Reject if name == locale.
|
|
34
34
|
{{/cap}}
|
|
35
35
|
|
|
36
|
+
## Platform detection (do this FIRST)
|
|
37
|
+
|
|
38
|
+
Read `qa/capabilities.yaml` and check the `platform` field.
|
|
39
|
+
|
|
40
|
+
- If `platform: mobile` → switch to the **mobile path** (below).
|
|
41
|
+
- If `platform: web` or field absent → follow the **web path** (existing behavior below, unchanged).
|
|
42
|
+
|
|
43
|
+
### Mobile path
|
|
44
|
+
|
|
45
|
+
When `platform: mobile`, mobile follows the **device locale** — there is no URL prefix, query param, or browser-storage mechanism to detect. Key differences from web (full detail in the `sungen-locale` skill):
|
|
46
|
+
|
|
47
|
+
- **Live capture**: use **Appium MCP** (`select_device` → `appium_session_management(create)` with the device at base locale → `appium_get_page_source` / `appium_screenshot`) — not Playwright browser tools.
|
|
48
|
+
- **Locale-switch mechanism**: `appium:language` / `appium:locale` capabilities + `noReset:false`, resolved automatically at runtime by `wdio.conf.ts`'s `resolveLocaleCaps()`. No fixture files needed.
|
|
49
|
+
- **Phases skipped**: 3.5/3.6 (browser storage diff/verify) and 6d/6f (Pages URL update, locale-config.json) are web-only — skip them. Phase 6 (mobile) writes only selectors `{{var}}`, the base `test-data/<feature>.yaml`, and the overlay `test-data/<feature>.<locale>.yaml`.
|
|
50
|
+
- **Compile**: `sungen generate --screen <name>` (the Appium adapter auto-selects for a mobile project).
|
|
51
|
+
- **Hand-off**: after a locale run, print `SUNGEN_ENV=<locale> npm run test:mobile` (or the `--flow` equivalent). If the device's locale code differs from the overlay's env name, set `SUNGEN_LOCALE=<code>` — `resolveLocaleCaps()` reads it as an override.
|
|
52
|
+
- **Reset after a locale run**: run once with `SUNGEN_LOCALE=en npm run test:mobile` to bring the device/app back to the base language before the next non-locale run.
|
|
53
|
+
- **iOS data-wipe**: `wdio.conf.ts`'s `beforeSession` hook already wipes the app's data container on iOS before any locale run (XCUITest's `noReset:false` alone doesn't clear a persisted in-app language). If an older mobile project's `wdio.conf.ts` predates this hook and iOS keeps the previous language, re-run `sungen init --mobile` to refresh it.
|
|
54
|
+
|
|
55
|
+
Stop here for mobile — do not continue to the web path below. Everything else (the 7-phase strategy, selector/test-data output shape, `{{var}}` overlay pattern) matches web; see the `sungen-locale` skill for the full mobile phase table.
|
|
56
|
+
|
|
36
57
|
## Auto-detect context
|
|
37
58
|
|
|
38
59
|
Same as `/sungen:run-test`:
|
|
@@ -114,10 +135,11 @@ Offer the user:
|
|
|
114
135
|
{{#cap parallel-subagents}}
|
|
115
136
|
- Do NOT run tests yourself. `/sungen:run-test` is the executor. This command only PREPS files.
|
|
116
137
|
- Do NOT modify `.feature` files. Localization happens entirely through `selectors/*.yaml` + `test-data/*.yaml`.
|
|
117
|
-
- Auth blocker on SAA staging? Use `--offline`. See [[saa-auth-blocker]] memory.
|
|
138
|
+
- Auth blocker on SAA staging (web)? Use `--offline`. See [[saa-auth-blocker]] memory.
|
|
139
|
+
- Mobile device/emulator unreachable? Use `--offline` — same fallback as web.
|
|
118
140
|
{{/cap}}
|
|
119
141
|
{{^cap parallel-subagents}}
|
|
120
142
|
- Do NOT run tests yourself. `/sungen:run-test` is the executor; this command only PREPS files.
|
|
121
143
|
- Do NOT modify `.feature` files. Localization lives in `selectors/*.yaml` + `test-data/*.yaml` only.
|
|
122
|
-
- Auth blocked? Use `--offline`.
|
|
144
|
+
- Auth blocked (web)? Use `--offline`. Mobile device unreachable? Same fallback.
|
|
123
145
|
{{/cap}}
|
|
@@ -78,19 +78,20 @@ The CLI reads the **per-target result file first** (co-located with `.spec.ts`),
|
|
|
78
78
|
|
|
79
79
|
---
|
|
80
80
|
|
|
81
|
-
## XLSX sheets — Auto / Manual split
|
|
81
|
+
## XLSX sheets — Overview + Auto / Manual split
|
|
82
82
|
|
|
83
|
-
The `.xlsx`
|
|
83
|
+
The `.xlsx` leads with an **`Overview`** roll-up sheet, then splits the test cases into detail sheets so QA manages the sets separately:
|
|
84
|
+
- **`Overview`** — first sheet. Same masthead (Sun\* logo, `<SCREEN> TESTCASE` title, No/Version/Issue-Date, ISO band) as the others, then a summary table: one row per following sheet with `# · Sheet name · Passed · Failed · Pending · N/A · Remain · Total`, plus a grand-total row. `Remain = Total − Passed − N/A` (i.e. Failed + Pending), reconciling with each detail sheet's own summary band.
|
|
84
85
|
- **`Auto`** — automatable test cases (`Auto` + `Not compiled`).
|
|
85
86
|
- **`Manual`** — `@manual` test cases (always present, header-only when there are none).
|
|
86
87
|
|
|
87
|
-
Multi-locale (no `SUNGEN_ENV`): one **`<LOCALE> Auto`** sheet per locale + a single shared **`Manual`** sheet (manual TCs are locale-invariant). The **CSV stays one file with every row** — the `Testcase type` column distinguishes Auto vs Manual.
|
|
88
|
+
Multi-locale (no `SUNGEN_ENV`): `Overview` + one **`<LOCALE> Auto`** sheet per locale + a single shared **`Manual`** sheet (manual TCs are locale-invariant). The **CSV stays one file with every row** — the `Testcase type` column distinguishes Auto vs Manual. The Overview lists every detail sheet, so each locale's counts appear on its own row.
|
|
88
89
|
|
|
89
90
|
---
|
|
90
91
|
|
|
91
92
|
## API delivery — extra worksheet
|
|
92
93
|
|
|
93
|
-
For **api-kind units** (`qa/api/<area>/`), the `.xlsx` gains a
|
|
94
|
+
For **api-kind units** (`qa/api/<area>/`), the `.xlsx` gains a final worksheet **`API detail`** (appended after Overview/Auto/Manual). The main BM-2-901-13 Testcases layout is unchanged. The CSV is unchanged (16-column, no extra sheet).
|
|
94
95
|
|
|
95
96
|
### Required sources (API detail sheet only)
|
|
96
97
|
|
|
@@ -25,6 +25,7 @@ import dotenv from 'dotenv';
|
|
|
25
25
|
dotenv.config({ path: '.env.appium' });
|
|
26
26
|
import * as fs from 'node:fs';
|
|
27
27
|
import * as path from 'node:path';
|
|
28
|
+
import { execSync } from 'node:child_process';
|
|
28
29
|
import PwShapeReporter from './specs/reporters/pw-shape-reporter';
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -104,13 +105,34 @@ const RUN_ANDROID = MOBILE_PLATFORM === 'android' || MOBILE_PLATFORM === 'both';
|
|
|
104
105
|
const RUN_IOS = MOBILE_PLATFORM === 'ios' || MOBILE_PLATFORM === 'both';
|
|
105
106
|
const IOS_APP = process.env.IOS_APP;
|
|
106
107
|
const IOS_BUNDLE_ID = process.env.IOS_BUNDLE_ID || '__IOS_BUNDLE_ID__';
|
|
107
|
-
const
|
|
108
|
-
|
|
108
|
+
const IOS_UDID = process.env.IOS_UDID;
|
|
109
|
+
// When a specific Simulator is targeted by IOS_UDID, XCUITest uses THAT device and IGNORES the
|
|
110
|
+
// deviceName/platformVersion caps — so a stale IOS_DEVICE/IOS_VERSION would MISLABEL the deliverable's
|
|
111
|
+
// "Test Environment". Resolve the real device name + iOS runtime from the UDID via simctl so the caps
|
|
112
|
+
// (and the reporter's label) reflect reality. Explicit IOS_DEVICE/IOS_VERSION still win; on a real
|
|
113
|
+
// device / non-mac, simctl fails → fall back to the provided/scaffold values.
|
|
114
|
+
function resolveSimDevice(udid: string): { name?: string; version?: string } {
|
|
115
|
+
try {
|
|
116
|
+
const data = JSON.parse(execSync('xcrun simctl list devices --json', { encoding: 'utf-8' })) as {
|
|
117
|
+
devices?: Record<string, Array<{ udid: string; name: string }>>;
|
|
118
|
+
};
|
|
119
|
+
for (const [runtime, devs] of Object.entries(data.devices || {})) {
|
|
120
|
+
const hit = devs.find((d) => d.udid === udid);
|
|
121
|
+
if (hit) {
|
|
122
|
+
const vm = /iOS-(\d+)-(\d+)/.exec(runtime); // com.apple.CoreSimulator.SimRuntime.iOS-26-3
|
|
123
|
+
return { name: hit.name, version: vm ? `${vm[1]}.${vm[2]}` : undefined };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch { /* simctl unavailable (real device / non-mac) → keep provided values */ }
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
const IOS_SIM = IOS_UDID ? resolveSimDevice(IOS_UDID) : {};
|
|
130
|
+
const IOS_DEVICE = process.env.IOS_DEVICE || IOS_SIM.name || '__IOS_DEVICE__';
|
|
131
|
+
const IOS_VERSION = process.env.IOS_VERSION || IOS_SIM.version || '__IOS_VERSION__';
|
|
109
132
|
// platformVersion is OPTIONAL on a real device: with IOS_UDID set, XCUITest reads the iOS version off
|
|
110
133
|
// the connected device. Only pass the cap when IOS_VERSION is a real value (not the unfilled scaffold
|
|
111
134
|
// placeholder). On a Simulator set it (or IOS_DEVICE) so Appium picks a matching runtime.
|
|
112
135
|
const HAS_REAL_IOS_VERSION = !!IOS_VERSION && IOS_VERSION !== '__IOS_VERSION__';
|
|
113
|
-
const IOS_UDID = process.env.IOS_UDID;
|
|
114
136
|
// Real iPhone (device) — WebDriverAgent must be code-signed onto it. Set IOS_TEAM_ID (your Apple
|
|
115
137
|
// Team ID) to switch the iOS cap into real-device mode; the Simulator ignores these (they're only
|
|
116
138
|
// emitted when IOS_TEAM_ID is present, so Simulator runs are unchanged). For a real device also set
|
|
@@ -208,6 +230,13 @@ export const config: WebdriverIO.Config = {
|
|
|
208
230
|
{
|
|
209
231
|
platformName: 'iOS',
|
|
210
232
|
'appium:automationName': 'XCUITest',
|
|
233
|
+
// Flutter apps NEVER report "idle" to XCUITest, so WDA always waits the FULL
|
|
234
|
+
// waitForIdleTimeout before EVERY interaction. The default (~10s) makes gesture
|
|
235
|
+
// loops blow the mocha timeout; 0 removes the wait entirely but then taps fire
|
|
236
|
+
// mid-transition/mid-launch and randomly miss (measured: ~2 flaky scenarios per
|
|
237
|
+
// run). 2s is the working balance — a universal settle that covers Flutter
|
|
238
|
+
// transitions while keeping bounded scroll/error paths well inside the budget.
|
|
239
|
+
'appium:waitForIdleTimeout': 2,
|
|
211
240
|
'appium:deviceName': IOS_DEVICE,
|
|
212
241
|
// Omit when unknown (real device, no IOS_VERSION) → XCUITest reads it from the device by udid.
|
|
213
242
|
...(HAS_REAL_IOS_VERSION ? { 'appium:platformVersion': IOS_VERSION } : {}),
|
|
@@ -253,6 +282,13 @@ export const config: WebdriverIO.Config = {
|
|
|
253
282
|
mochaOpts: {
|
|
254
283
|
ui: 'bdd',
|
|
255
284
|
timeout: 120000,
|
|
285
|
+
// One retry per test: the relaunch-per-scenario pattern on a Flutter sim has a residual
|
|
286
|
+
// launch race (~10%/scenario — the first interaction can fire before the app routes
|
|
287
|
+
// touches), with a RANDOM victim each run. A retry re-runs the full beforeEach (fresh
|
|
288
|
+
// relaunch), so race victims recover on attempt 2 while real failures (wrong expected
|
|
289
|
+
// value, missing element) fail both attempts and still report Failed. The reporter and
|
|
290
|
+
// delivery already take the LAST attempt.
|
|
291
|
+
retries: 1,
|
|
256
292
|
},
|
|
257
293
|
|
|
258
294
|
// iOS + locale: XCUITest's `noReset:false` does NOT actually clear the app's data container
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open the user's editor to compose feedback text, à la `git commit`.
|
|
3
|
+
*
|
|
4
|
+
* The feedback body is MARKDOWN, so the editor buffer is left completely
|
|
5
|
+
* untouched — no comment/instruction lines are injected or stripped (that would
|
|
6
|
+
* clobber markdown `#` headings). Guidance is printed to the terminal before the
|
|
7
|
+
* editor launches instead. Returns the trimmed body, or null when it is empty
|
|
8
|
+
* (caller treats that as "abort").
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from 'child_process';
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
|
|
16
|
+
/** Resolve the editor command, mirroring git's precedence. */
|
|
17
|
+
function resolveEditor(): string {
|
|
18
|
+
return (
|
|
19
|
+
process.env.VISUAL ||
|
|
20
|
+
process.env.EDITOR ||
|
|
21
|
+
(process.platform === 'win32' ? 'notepad' : 'vi')
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Open the editor and return the composed feedback (verbatim, trimmed), or null
|
|
27
|
+
* if empty/aborted. Throws if the editor cannot be launched or exits non-zero.
|
|
28
|
+
*/
|
|
29
|
+
export function composeInEditor(): string | null {
|
|
30
|
+
const file = path.join(os.tmpdir(), `sungen-feedback-${process.pid}-${Date.now()}.md`);
|
|
31
|
+
fs.writeFileSync(file, '', 'utf8');
|
|
32
|
+
try {
|
|
33
|
+
const editor = resolveEditor();
|
|
34
|
+
console.error('Composing feedback in your editor (markdown supported). Save & close to submit; leave empty to abort.');
|
|
35
|
+
// shell:true so editors with args work (e.g. VISUAL="code --wait").
|
|
36
|
+
const result = spawnSync(`${editor} "${file}"`, { shell: true, stdio: 'inherit' });
|
|
37
|
+
if (result.error) throw new Error(`Could not launch editor "${editor}": ${result.error.message}`);
|
|
38
|
+
if (typeof result.status === 'number' && result.status !== 0) {
|
|
39
|
+
throw new Error(`Editor "${editor}" exited with code ${result.status}.`);
|
|
40
|
+
}
|
|
41
|
+
// Verbatim — only trim outer whitespace so markdown (headings, lists, code
|
|
42
|
+
// fences, HTML comments) is preserved exactly as written.
|
|
43
|
+
const body = fs.readFileSync(file, 'utf8').trim();
|
|
44
|
+
return body.length > 0 ? body : null;
|
|
45
|
+
} finally {
|
|
46
|
+
try { fs.unlinkSync(file); } catch { /* best effort */ }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin client for submitting product feedback to the FastSchema backend.
|
|
3
|
+
* Raw fetch (the fastschema npm SDK is browser/cookie-oriented and has no
|
|
4
|
+
* bearer-token content client). author is NEVER sent — the server stamps it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Thrown on 401 so the caller can prompt a re-login and retry once. */
|
|
8
|
+
export class AuthExpiredError extends Error {
|
|
9
|
+
constructor(message = 'Authentication expired.') {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'AuthExpiredError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FeedbackPayload {
|
|
16
|
+
content: string;
|
|
17
|
+
category: string;
|
|
18
|
+
sungen_version?: string;
|
|
19
|
+
os?: string;
|
|
20
|
+
source: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeBase(url: string): string {
|
|
24
|
+
return url.replace(/\/+$/, '');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* POST one feedback record. Returns the created row id.
|
|
29
|
+
* @throws AuthExpiredError on 401; Error (scrubbed) on any other non-2xx.
|
|
30
|
+
*/
|
|
31
|
+
export async function submitFeedback(
|
|
32
|
+
apiBaseUrl: string,
|
|
33
|
+
token: string,
|
|
34
|
+
payload: FeedbackPayload,
|
|
35
|
+
): Promise<{ id: number | string }> {
|
|
36
|
+
let res: Response;
|
|
37
|
+
try {
|
|
38
|
+
res = await fetch(`${normalizeBase(apiBaseUrl)}/api/content/feedback`, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
Authorization: `Bearer ${token}`,
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify(payload),
|
|
45
|
+
});
|
|
46
|
+
} catch (err: any) {
|
|
47
|
+
throw new Error(`Network error reaching the feedback backend: ${err?.message ?? 'unknown'}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (res.status === 401) throw new AuthExpiredError();
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
// Scrub the body — never echo a raw server error that might carry internals.
|
|
53
|
+
throw new Error(`Feedback submission failed (HTTP ${res.status}).`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const body = (await res.json().catch(() => ({}))) as any;
|
|
57
|
+
// FastSchema content create returns the row (id at top level or under .data).
|
|
58
|
+
// Only accept an actual id value — never fall back to the whole object (would
|
|
59
|
+
// print "[object Object]").
|
|
60
|
+
const id = body?.id ?? body?.data?.id ?? 'unknown';
|
|
61
|
+
return { id };
|
|
62
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read piped stdin to a string. Returns null when stdin is a TTY (interactive),
|
|
3
|
+
* so callers can distinguish "no piped input" from "empty pipe".
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function readStdin(): Promise<string | null> {
|
|
7
|
+
if (process.stdin.isTTY) return Promise.resolve(null);
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
let data = '';
|
|
10
|
+
process.stdin.setEncoding('utf8');
|
|
11
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
12
|
+
process.stdin.on('end', () => resolve(data));
|
|
13
|
+
process.stdin.on('error', reject);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves the feedback backend base URL with a fixed precedence so `login` and
|
|
3
|
+
* `feedback` agree on where to talk.
|
|
4
|
+
*
|
|
5
|
+
* Precedence: explicit flag > SUNGEN_API_BASE_URL env > stored credential's
|
|
6
|
+
* api_base_url > DEFAULT_API_BASE_URL.
|
|
7
|
+
*
|
|
8
|
+
* DEFAULT_API_BASE_URL is the deployed production host, so the common case needs
|
|
9
|
+
* no config; override via --api-base-url / SUNGEN_API_BASE_URL for dev/staging.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { loadCredentials } from './credentials-store';
|
|
13
|
+
|
|
14
|
+
/** Deployed feedback backend (production). Override with SUNGEN_API_BASE_URL locally. */
|
|
15
|
+
export const DEFAULT_API_BASE_URL = 'https://sungen.sun-asterisk.vn';
|
|
16
|
+
|
|
17
|
+
export function resolveApiBaseUrl(flagValue?: string): string {
|
|
18
|
+
if (flagValue) return flagValue;
|
|
19
|
+
if (process.env.SUNGEN_API_BASE_URL) return process.env.SUNGEN_API_BASE_URL;
|
|
20
|
+
const stored = loadCredentials();
|
|
21
|
+
if (stored?.api_base_url) return stored.api_base_url;
|
|
22
|
+
return DEFAULT_API_BASE_URL;
|
|
23
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-level credential storage at ~/.sungen/credentials.json.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately user-scoped (not project-local like the Figma PAT in .env): the
|
|
5
|
+
* login token is tied to the person and works across every sungen project.
|
|
6
|
+
* File mode 0600, parent dir 0700 — the token is a bearer secret.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as os from 'os';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import type { Credentials } from './sungen-auth-types';
|
|
13
|
+
|
|
14
|
+
const DIR_NAME = '.sungen';
|
|
15
|
+
const FILE_NAME = 'credentials.json';
|
|
16
|
+
|
|
17
|
+
/** Absolute path to the credentials file (~/.sungen/credentials.json). */
|
|
18
|
+
export function credentialsPath(): string {
|
|
19
|
+
return path.join(os.homedir(), DIR_NAME, FILE_NAME);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Load stored credentials, or null when absent / unreadable / malformed. */
|
|
23
|
+
export function loadCredentials(): Credentials | null {
|
|
24
|
+
try {
|
|
25
|
+
const raw = fs.readFileSync(credentialsPath(), 'utf8');
|
|
26
|
+
const parsed = JSON.parse(raw) as Credentials;
|
|
27
|
+
if (!parsed || typeof parsed.token !== 'string') return null;
|
|
28
|
+
return parsed;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Persist credentials with restrictive permissions (dir 0700, file 0600). */
|
|
35
|
+
export function saveCredentials(creds: Credentials): void {
|
|
36
|
+
const dir = path.join(os.homedir(), DIR_NAME);
|
|
37
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
38
|
+
const fp = credentialsPath();
|
|
39
|
+
// Write then chmod so an existing looser-mode file is tightened too.
|
|
40
|
+
fs.writeFileSync(fp, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
41
|
+
fs.chmodSync(fp, 0o600);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Remove the credentials file; no-op when absent. */
|
|
45
|
+
export function clearCredentials(): void {
|
|
46
|
+
try {
|
|
47
|
+
fs.unlinkSync(credentialsPath());
|
|
48
|
+
} catch {
|
|
49
|
+
/* already gone */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when the credential carries an expiry that is already past (no refresh token — prompt re-login on expiry). */
|
|
54
|
+
export function isExpired(creds: Credentials): boolean {
|
|
55
|
+
if (typeof creds.expires_at !== 'number') return false;
|
|
56
|
+
return creds.expires_at * 1000 <= Date.now();
|
|
57
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 6-call RFC 8252 CLI login flow against FastSchema v0.13.0.
|
|
3
|
+
*
|
|
4
|
+
* Base path is /api/auth (verified live against the running v0.13.0 server;
|
|
5
|
+
* the earlier /api/user/auth contract was wrong). PKCE-S256 + single-use
|
|
6
|
+
* one-time code; the JWT is minted server-side
|
|
7
|
+
* and only delivered via the server-to-server `exchange` — it never appears in
|
|
8
|
+
* the browser. This module does NO disk I/O so it stays unit-testable.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
ExchangeResponse,
|
|
13
|
+
InitiateResponse,
|
|
14
|
+
MeResponse,
|
|
15
|
+
PkceMaterial,
|
|
16
|
+
} from './sungen-auth-types';
|
|
17
|
+
|
|
18
|
+
export interface LoginFlowDeps {
|
|
19
|
+
apiBaseUrl: string;
|
|
20
|
+
pkce: PkceMaterial;
|
|
21
|
+
correlation: string;
|
|
22
|
+
redirectUri: string;
|
|
23
|
+
/** Open the authorize URL; return false to trigger the manual-paste fallback. */
|
|
24
|
+
openBrowser: (url: string) => boolean;
|
|
25
|
+
/** Resolve with the one-time code once the loopback callback fires. */
|
|
26
|
+
waitForCode: (correlation: string, timeoutMs?: number) => Promise<string>;
|
|
27
|
+
/** Optional sink for user-facing progress lines (defaults to console.log). */
|
|
28
|
+
log?: (msg: string) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LoginResult {
|
|
32
|
+
token: string;
|
|
33
|
+
email: string;
|
|
34
|
+
expires_at?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Strip a trailing slash so path joins never double up. */
|
|
38
|
+
function normalizeBase(url: string): string {
|
|
39
|
+
return url.replace(/\/+$/, '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function postJson<T>(url: string, body: unknown): Promise<{ ok: boolean; status: number; data: T | null }> {
|
|
43
|
+
let res: Response;
|
|
44
|
+
try {
|
|
45
|
+
res = await fetch(url, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
body: JSON.stringify(body),
|
|
49
|
+
});
|
|
50
|
+
} catch (err: any) {
|
|
51
|
+
throw new Error(`Network error reaching ${url}: ${err?.message ?? 'unknown'}`);
|
|
52
|
+
}
|
|
53
|
+
let data: T | null = null;
|
|
54
|
+
try {
|
|
55
|
+
const json: any = await res.json();
|
|
56
|
+
// FastSchema wraps success payloads in { data: ... } and errors in { error: ... }.
|
|
57
|
+
data = (json && json.data !== undefined ? json.data : json) as T;
|
|
58
|
+
} catch {
|
|
59
|
+
/* non-JSON body */
|
|
60
|
+
}
|
|
61
|
+
return { ok: res.ok, status: res.status, data };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run the full login flow. Throws with a clear, distinct message for each
|
|
66
|
+
* failure mode (feature-off, missing challenge, exchange-401, etc.).
|
|
67
|
+
*/
|
|
68
|
+
export async function runLoginFlow(deps: LoginFlowDeps): Promise<LoginResult> {
|
|
69
|
+
const base = normalizeBase(deps.apiBaseUrl);
|
|
70
|
+
const log = deps.log ?? ((m: string) => console.log(m));
|
|
71
|
+
|
|
72
|
+
// 1. initiate — exchange PKCE challenge + loopback redirect for a carrier.
|
|
73
|
+
const init = await postJson<InitiateResponse>(`${base}/api/auth/cli/initiate`, {
|
|
74
|
+
redirect_uri: deps.redirectUri,
|
|
75
|
+
correlation: deps.correlation,
|
|
76
|
+
code_challenge: deps.pkce.challenge,
|
|
77
|
+
});
|
|
78
|
+
if (init.status === 403) {
|
|
79
|
+
throw new Error('CLI login is disabled on the server (AUTH_CLI_LOGIN_ENABLED=false).');
|
|
80
|
+
}
|
|
81
|
+
if (init.status === 400) {
|
|
82
|
+
throw new Error('Server rejected the login request (missing/invalid PKCE challenge or redirect).');
|
|
83
|
+
}
|
|
84
|
+
if (!init.ok || !init.data?.authorize_url) {
|
|
85
|
+
throw new Error(`Login initiate failed (HTTP ${init.status}).`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. open the dashboard login page (authorize_url is a path → join with base).
|
|
89
|
+
const authorizeUrl = `${base}${init.data.authorize_url}`;
|
|
90
|
+
// Always print the URL: openBrowser reports success even when the launcher
|
|
91
|
+
// fails asynchronously (e.g. headless box with no xdg-open), so the paste
|
|
92
|
+
// path must stay visible regardless.
|
|
93
|
+
const opened = deps.openBrowser(authorizeUrl);
|
|
94
|
+
if (opened) {
|
|
95
|
+
log(`Opened your browser to complete login. Waiting…\nIf it didn't open, visit:\n ${authorizeUrl}`);
|
|
96
|
+
} else {
|
|
97
|
+
log(`Open this URL in your browser to continue login:\n ${authorizeUrl}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 3. wait for the loopback callback (asserts state===correlation inside).
|
|
101
|
+
const code = await deps.waitForCode(deps.correlation);
|
|
102
|
+
|
|
103
|
+
// 4. exchange the one-time code + verifier for the JWT.
|
|
104
|
+
const exch = await postJson<ExchangeResponse>(`${base}/api/auth/exchange`, {
|
|
105
|
+
code,
|
|
106
|
+
code_verifier: deps.pkce.verifier,
|
|
107
|
+
});
|
|
108
|
+
if (exch.status === 401) {
|
|
109
|
+
throw new Error('Token exchange rejected (code expired or verifier mismatch). Try `sungen login` again.');
|
|
110
|
+
}
|
|
111
|
+
if (!exch.ok || !exch.data?.token) {
|
|
112
|
+
throw new Error(`Token exchange failed (HTTP ${exch.status}).`);
|
|
113
|
+
}
|
|
114
|
+
const token = exch.data.token;
|
|
115
|
+
// Backend reports expiry as an ISO `expires` string; accept a numeric
|
|
116
|
+
// `expires_at` epoch too. Absent → rely on 401-on-expiry (single token, no refresh).
|
|
117
|
+
const expires_at =
|
|
118
|
+
exch.data.expires_at ??
|
|
119
|
+
(exch.data.expires ? Math.floor(Date.parse(exch.data.expires) / 1000) : undefined);
|
|
120
|
+
|
|
121
|
+
// 5. confirm identity (Bearer) and capture the email.
|
|
122
|
+
let me: Response;
|
|
123
|
+
try {
|
|
124
|
+
me = await fetch(`${base}/api/auth/me`, { headers: { Authorization: `Bearer ${token}` } });
|
|
125
|
+
} catch (err: any) {
|
|
126
|
+
throw new Error(`Network error confirming identity: ${err?.message ?? 'unknown'}`);
|
|
127
|
+
}
|
|
128
|
+
if (!me.ok) {
|
|
129
|
+
throw new Error(`Logged in but identity check failed (HTTP ${me.status}).`);
|
|
130
|
+
}
|
|
131
|
+
const meJson: any = await me.json();
|
|
132
|
+
const meBody = (meJson && meJson.data !== undefined ? meJson.data : meJson) as MeResponse;
|
|
133
|
+
|
|
134
|
+
return { token, email: meBody.email, expires_at };
|
|
135
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-shot loopback HTTP listener for the CLI login redirect.
|
|
3
|
+
*
|
|
4
|
+
* Binds 127.0.0.1 on an ephemeral port, waits for exactly one
|
|
5
|
+
* GET /callback?code=<otc>&state=<correlation>, asserts state===correlation
|
|
6
|
+
* (CSRF guard), shows a "you may close this tab" page, then closes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as http from 'http';
|
|
10
|
+
import { AddressInfo, Socket } from 'net';
|
|
11
|
+
|
|
12
|
+
export interface Loopback {
|
|
13
|
+
/** The ephemeral port the listener bound to. */
|
|
14
|
+
port: number;
|
|
15
|
+
/** Full redirect_uri to hand to the backend (http://127.0.0.1:<port>/callback). */
|
|
16
|
+
redirectUri: string;
|
|
17
|
+
/** Resolve with the one-time code once the browser hits /callback; reject on timeout/mismatch. */
|
|
18
|
+
waitForCode(correlation: string, timeoutMs?: number): Promise<string>;
|
|
19
|
+
/** Force-close the listener (idempotent). */
|
|
20
|
+
close(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const CLOSE_PAGE =
|
|
24
|
+
'<!doctype html><meta charset="utf-8"><title>sungen login</title>' +
|
|
25
|
+
'<body style="font-family:system-ui;padding:3rem;text-align:center">' +
|
|
26
|
+
'<h2>Login complete</h2><p>You may close this tab and return to the terminal.</p></body>';
|
|
27
|
+
|
|
28
|
+
const DEFAULT_TIMEOUT_MS = 180_000;
|
|
29
|
+
|
|
30
|
+
/** Start the loopback listener. Resolves once it is bound and ready. */
|
|
31
|
+
export function startLoopback(): Promise<Loopback> {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
let resolveCode: ((code: string) => void) | null = null;
|
|
34
|
+
let rejectCode: ((err: Error) => void) | null = null;
|
|
35
|
+
|
|
36
|
+
const server = http.createServer((req, res) => {
|
|
37
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
38
|
+
if (url.pathname !== '/callback') {
|
|
39
|
+
res.writeHead(404, { Connection: 'close' }).end('not found');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// Connection: close so the browser doesn't hold a keep-alive socket open —
|
|
43
|
+
// otherwise server.close() would never fire and the CLI would hang after login.
|
|
44
|
+
res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' }).end(CLOSE_PAGE);
|
|
45
|
+
const code = url.searchParams.get('code');
|
|
46
|
+
const state = url.searchParams.get('state');
|
|
47
|
+
// Hand the parsed values to the pending waitForCode promise.
|
|
48
|
+
pending = { code, state };
|
|
49
|
+
drain();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Track live sockets so shutdown() can destroy any keep-alive stragglers.
|
|
53
|
+
const sockets = new Set<Socket>();
|
|
54
|
+
server.on('connection', (s) => {
|
|
55
|
+
sockets.add(s);
|
|
56
|
+
s.on('close', () => sockets.delete(s));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** Close the server AND destroy lingering sockets so the event loop can drain. */
|
|
60
|
+
function shutdown(): void {
|
|
61
|
+
if (server.listening) server.close();
|
|
62
|
+
for (const s of sockets) s.destroy();
|
|
63
|
+
sockets.clear();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Buffer the callback result until a waiter (with its correlation) arrives.
|
|
67
|
+
let pending: { code: string | null; state: string | null } | null = null;
|
|
68
|
+
let expectedCorrelation: string | null = null;
|
|
69
|
+
|
|
70
|
+
function drain(): void {
|
|
71
|
+
if (!pending || !resolveCode || !rejectCode || expectedCorrelation == null) return;
|
|
72
|
+
const { code, state } = pending;
|
|
73
|
+
if (state !== expectedCorrelation) {
|
|
74
|
+
rejectCode(new Error('Login state mismatch — possible CSRF; aborting.'));
|
|
75
|
+
} else if (!code) {
|
|
76
|
+
rejectCode(new Error('Login callback missing authorization code.'));
|
|
77
|
+
} else {
|
|
78
|
+
resolveCode(code);
|
|
79
|
+
}
|
|
80
|
+
// Let the response flush first, then tear everything down.
|
|
81
|
+
setImmediate(shutdown);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
server.on('error', reject);
|
|
85
|
+
server.listen(0, '127.0.0.1', () => {
|
|
86
|
+
const port = (server.address() as AddressInfo).port;
|
|
87
|
+
resolve({
|
|
88
|
+
port,
|
|
89
|
+
redirectUri: `http://127.0.0.1:${port}/callback`,
|
|
90
|
+
waitForCode(correlation, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
91
|
+
expectedCorrelation = correlation;
|
|
92
|
+
return new Promise<string>((res, rej) => {
|
|
93
|
+
resolveCode = res;
|
|
94
|
+
rejectCode = rej;
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
shutdown();
|
|
97
|
+
rej(new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for browser login.`));
|
|
98
|
+
}, timeoutMs);
|
|
99
|
+
timer.unref?.();
|
|
100
|
+
// Wrap to always clear the timer.
|
|
101
|
+
const origRes = resolveCode, origRej = rejectCode;
|
|
102
|
+
resolveCode = (c) => { clearTimeout(timer); origRes!(c); };
|
|
103
|
+
rejectCode = (e) => { clearTimeout(timer); origRej!(e); };
|
|
104
|
+
drain();
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
close() {
|
|
108
|
+
shutdown();
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform "open this URL in the default browser" helper.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately dependency-free — a small platform switch instead of pulling in
|
|
5
|
+
* the `open` npm package. Returns false on failure so the caller can fall back
|
|
6
|
+
* to printing the URL for manual paste.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from 'child_process';
|
|
10
|
+
|
|
11
|
+
/** Attempt to open `url` in the system browser. Returns true if a launcher was spawned. */
|
|
12
|
+
export function openBrowser(url: string): boolean {
|
|
13
|
+
let command: string;
|
|
14
|
+
let args: string[];
|
|
15
|
+
|
|
16
|
+
switch (process.platform) {
|
|
17
|
+
case 'darwin':
|
|
18
|
+
command = 'open';
|
|
19
|
+
args = [url];
|
|
20
|
+
break;
|
|
21
|
+
case 'win32':
|
|
22
|
+
// `start` is a cmd builtin; the empty "" is the window-title placeholder.
|
|
23
|
+
command = 'cmd';
|
|
24
|
+
args = ['/c', 'start', '', url];
|
|
25
|
+
break;
|
|
26
|
+
default:
|
|
27
|
+
command = 'xdg-open';
|
|
28
|
+
args = [url];
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
34
|
+
// A missing launcher fails asynchronously here, after we've already returned
|
|
35
|
+
// true; that's why callers also print the URL rather than relying on this.
|
|
36
|
+
child.on('error', () => { /* best-effort launch */ });
|
|
37
|
+
child.unref();
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|