@velaro/cli 1.4.53 → 1.4.55

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.
@@ -31,8 +31,10 @@ const execFileAsync = promisify(execFile);
31
31
  const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
32
32
 
33
33
  const APP_URLS = {
34
- admin: process.env.ADMIN_UI_URL || 'https://admin-staging.velaro.com',
35
- messaging: process.env.MSG_UI_URL || 'https://messaging-staging.velaro.com',
34
+ admin: process.env.ADMIN_UI_URL || 'https://admin-staging.velaro.com',
35
+ messaging: process.env.MSG_UI_URL || 'https://messaging-staging.velaro.com',
36
+ // v10 (livefluence). Console-staging, never prod -- same "staging only" rule as admin/messaging.
37
+ livefluence: process.env.LIVEFLUENCE_UI_URL || 'https://console-staging.velaro.com',
36
38
  };
37
39
 
38
40
  // Key Vault fallback for credentials — used only when neither E2E_USER_* nor TEST_USER_*
@@ -41,6 +43,14 @@ const KV_NAME = process.env.KB_SCREENSHOT_KV_NAME || 'velaro-e
41
43
  const KV_EMAIL_SECRET = process.env.KB_SCREENSHOT_KV_EMAIL_SECRET || 'claude-test-agent-staging1032-email';
42
44
  const KV_PASSWORD_SECRET = process.env.KB_SCREENSHOT_KV_PASSWORD_SECRET || 'claude-test-agent-staging1032-password';
43
45
 
46
+ // v10 (livefluence) console-staging uses a DIFFERENT test account than admin/messaging's
47
+ // default above -- confirmed live 2026-09-18 (memory: project_test_agent_reset_and_transfer_test_gaps_2026-09-15).
48
+ // Never silently reuse the admin/messaging KV secret names for v10; they resolve to a
49
+ // different account that has no guarantee of being provisioned on site 1032's v10 side.
50
+ const LIVEFLUENCE_KV_NAME = process.env.LIVEFLUENCE_KB_SCREENSHOT_KV_NAME || 'velaro-eastus-stg-kv';
51
+ const LIVEFLUENCE_KV_USERNAME_SECRET = process.env.LIVEFLUENCE_KB_SCREENSHOT_KV_USERNAME_SECRET || 'claude-test-agent-staging1032-username';
52
+ const LIVEFLUENCE_KV_PASSWORD_SECRET = process.env.LIVEFLUENCE_KB_SCREENSHOT_KV_PASSWORD_SECRET || 'claude-test-agent-staging1032-password';
53
+
44
54
  /**
45
55
  * Pulls the email/password pair from Azure Key Vault via DefaultAzureCredential.
46
56
  * Locally this picks up the operator's own `az login` session automatically — no
@@ -49,16 +59,16 @@ const KV_PASSWORD_SECRET = process.env.KB_SCREENSHOT_KV_PASSWORD_SECRET || 'clau
49
59
  * identity) to authenticate — out of scope here since this CLI command is
50
60
  * operator-run today, not a CI step.
51
61
  */
52
- async function loadCredentialsFromKeyVault() {
62
+ async function loadCredentialsFromKeyVault(vaultName, emailSecretName, passwordSecretName) {
53
63
  const { SecretClient } = await import('@azure/keyvault-secrets');
54
64
  const { DefaultAzureCredential } = await import('@azure/identity');
55
65
 
56
- const vaultUrl = `https://${KV_NAME}.vault.azure.net`;
66
+ const vaultUrl = `https://${vaultName}.vault.azure.net`;
57
67
  const client = new SecretClient(vaultUrl, new DefaultAzureCredential());
58
68
 
59
69
  const [emailSecret, passwordSecret] = await Promise.all([
60
- client.getSecret(KV_EMAIL_SECRET),
61
- client.getSecret(KV_PASSWORD_SECRET),
70
+ client.getSecret(emailSecretName),
71
+ client.getSecret(passwordSecretName),
62
72
  ]);
63
73
 
64
74
  return { email: emailSecret.value, password: passwordSecret.value };
@@ -78,15 +88,34 @@ async function loadPlaywright() {
78
88
 
79
89
  /**
80
90
  * Logs into the given app on staging using the same form-fill steps as
81
- * e2e/tests/auth.setup.ts, navigates to `route`, and screenshots to `outputPath`.
91
+ * e2e/tests/auth.setup.ts, navigates to `route`, optionally clicks a selector,
92
+ * and screenshots to `outputPath`.
93
+ *
94
+ * @param {object} [opts]
95
+ * @param {string} [opts.click] Selector (any Playwright engine — CSS, `text=`, `role=`) to
96
+ * click after the route loads, before capturing. Needed to reach anything not visible on
97
+ * initial page load — an export menu, a "..." row action, a modal trigger.
98
+ * @param {boolean} [opts.popup] The click in `opts.click` opens a NEW browser window/tab
99
+ * (`window.open`, `target="_blank"`) rather than something in the current page — e.g. a
100
+ * per-transcript "Export" action. Without this, `page.screenshot()` still captures the
101
+ * ORIGINAL page (usually just the button that was clicked), because Playwright never
102
+ * automatically follows focus to a new window. Root cause of the 2026-09-18 KB gap: the
103
+ * transcript-export screenshot needed this and the tool had no way to follow the popup.
82
104
  */
83
- export async function loginAndScreenshot(app, route, outputPath) {
105
+ export async function loginAndScreenshot(app, route, outputPath, opts = {}) {
106
+ const baseUrl = APP_URLS[app];
107
+ if (!baseUrl) throw new Error(`Unknown app "${app}" — expected "admin", "messaging", or "livefluence".`);
108
+
109
+ const isLivefluence = app === 'livefluence';
110
+
84
111
  let email = process.env.E2E_USER_EMAIL || process.env.TEST_USER_EMAIL;
85
112
  let password = process.env.E2E_USER_PASSWORD || process.env.TEST_USER_PASSWORD;
86
113
 
87
114
  if (!email || !password) {
88
115
  try {
89
- const kvCreds = await loadCredentialsFromKeyVault();
116
+ const kvCreds = isLivefluence
117
+ ? await loadCredentialsFromKeyVault(LIVEFLUENCE_KV_NAME, LIVEFLUENCE_KV_USERNAME_SECRET, LIVEFLUENCE_KV_PASSWORD_SECRET)
118
+ : await loadCredentialsFromKeyVault(KV_NAME, KV_EMAIL_SECRET, KV_PASSWORD_SECRET);
90
119
  email = kvCreds.email;
91
120
  password = kvCreds.password;
92
121
  } catch (err) {
@@ -99,16 +128,16 @@ export async function loginAndScreenshot(app, route, outputPath) {
99
128
  }
100
129
 
101
130
  if (!email || !password) {
131
+ const [vault, userSecret, passSecret] = isLivefluence
132
+ ? [LIVEFLUENCE_KV_NAME, LIVEFLUENCE_KV_USERNAME_SECRET, LIVEFLUENCE_KV_PASSWORD_SECRET]
133
+ : [KV_NAME, KV_EMAIL_SECRET, KV_PASSWORD_SECRET];
102
134
  throw new Error(
103
135
  'E2E_USER_EMAIL/E2E_USER_PASSWORD (or TEST_USER_EMAIL/TEST_USER_PASSWORD) must be set, ' +
104
- `and Key Vault (${KV_NAME}) did not return usable values for secrets ` +
105
- `"${KV_EMAIL_SECRET}"/"${KV_PASSWORD_SECRET}".`
136
+ `and Key Vault (${vault}) did not return usable values for secrets ` +
137
+ `"${userSecret}"/"${passSecret}".`
106
138
  );
107
139
  }
108
140
 
109
- const baseUrl = APP_URLS[app];
110
- if (!baseUrl) throw new Error(`Unknown app "${app}" — expected "admin" or "messaging".`);
111
-
112
141
  const { chromium } = await loadPlaywright();
113
142
  const browser = await chromium.launch();
114
143
 
@@ -116,30 +145,52 @@ export async function loginAndScreenshot(app, route, outputPath) {
116
145
  const context = await browser.newContext();
117
146
  const page = await context.newPage();
118
147
 
119
- // ── Login (adapted from e2e/tests/auth.setup.ts) ──────────────────────────
120
- // Role/label based selectors, not type-attribute selectors — the CIAM login page's
121
- // markup does not reliably render the email field as a literal input[type="email"]
122
- // or the submit controls as input[type="submit"] (found 2026-08-01 in auth.setup.ts,
123
- // confirmed again 2026-08-13 against live admin-staging while verifying the Key
124
- // Vault credential fallback below: the email step is input[name="username"]
125
- // type="text", and both submit controls are <button type="submit">). Role/label
126
- // selectors track the accessible page contract instead of an implementation detail
127
- // that can change under us.
128
- await page.goto(baseUrl);
129
- await page.waitForURL(/login\.velaro\.com/, { timeout: 30_000 });
130
-
131
- await page.getByRole('textbox', { name: /email/i }).fill(email);
132
- await page.getByRole('button', { name: /next|sign in/i }).click();
133
-
134
- await page.getByLabel(/password/i).fill(password);
135
- await page.getByRole('button', { name: /sign in|next/i }).click();
136
-
137
- // Handle "Stay signed in?" prompt — not always shown.
138
- try {
139
- await page.getByRole('button', { name: /yes|no|stay signed in/i }).first().click({ timeout: 5_000 });
140
- } catch { /* prompt not shown — proceed */ }
141
-
142
- await page.waitForURL(/velaro\.com/, { timeout: 30_000 });
148
+ if (isLivefluence) {
149
+ // ── v10 login (Client.ConsoleNew's own /Account/Login form, no CIAM redirect) ──
150
+ // Direct email+password form on the same host -- confirmed live 2026-09-18 via
151
+ // interactive login at app.velaro.com/Account/Login. Unlike admin/messaging, there
152
+ // is no login.velaro.com hop and no "stay signed in" prompt to handle.
153
+ await page.goto(new URL('/Account/Login', baseUrl).toString());
154
+ // The field's accessible label/placeholder is literally "Username" (an email
155
+ // address is entered into it, but it is NOT labeled "Email") -- confirmed live
156
+ // 2026-09-18 against the real login form; an /email/i selector silently matched
157
+ // nothing and the fill() was a no-op, which the old broad waitForURL(/velaro\.com/)
158
+ // masked because that regex also matches the login page's own URL.
159
+ await page.getByPlaceholder(/username/i).fill(email);
160
+ await page.getByPlaceholder(/password/i).fill(password);
161
+ // Exact match -- "Sign in with SSO" also matches a loose /sign in/i regex and sits
162
+ // right next to the real submit button (confirmed live 2026-09-18, strict-mode
163
+ // violation on first test run).
164
+ await page.getByRole('button', { name: 'Sign In', exact: true }).click();
165
+ // Must NOT match the login page's own URL -- wait for the post-login redirect away
166
+ // from /Account/Login specifically, not just "somewhere on velaro.com".
167
+ await page.waitForURL((url) => !url.pathname.toLowerCase().includes('/account/login'), { timeout: 30_000 });
168
+ } else {
169
+ // ── Login (adapted from e2e/tests/auth.setup.ts) ──────────────────────────
170
+ // Role/label based selectors, not type-attribute selectors — the CIAM login page's
171
+ // markup does not reliably render the email field as a literal input[type="email"]
172
+ // or the submit controls as input[type="submit"] (found 2026-08-01 in auth.setup.ts,
173
+ // confirmed again 2026-08-13 against live admin-staging while verifying the Key
174
+ // Vault credential fallback below: the email step is input[name="username"]
175
+ // type="text", and both submit controls are <button type="submit">). Role/label
176
+ // selectors track the accessible page contract instead of an implementation detail
177
+ // that can change under us.
178
+ await page.goto(baseUrl);
179
+ await page.waitForURL(/login\.velaro\.com/, { timeout: 30_000 });
180
+
181
+ await page.getByRole('textbox', { name: /email/i }).fill(email);
182
+ await page.getByRole('button', { name: /next|sign in/i }).click();
183
+
184
+ await page.getByLabel(/password/i).fill(password);
185
+ await page.getByRole('button', { name: /sign in|next/i }).click();
186
+
187
+ // Handle "Stay signed in?" prompt — not always shown.
188
+ try {
189
+ await page.getByRole('button', { name: /yes|no|stay signed in/i }).first().click({ timeout: 5_000 });
190
+ } catch { /* prompt not shown — proceed */ }
191
+
192
+ await page.waitForURL(/velaro\.com/, { timeout: 30_000 });
193
+ }
143
194
 
144
195
  // ── Navigate + capture ──────────────────────────────────────────────────
145
196
  const target = new URL(route, baseUrl).toString();
@@ -149,7 +200,28 @@ export async function loginAndScreenshot(app, route, outputPath) {
149
200
  // capture just because the network never goes fully idle.
150
201
  });
151
202
 
152
- await page.screenshot({ path: outputPath, fullPage: true });
203
+ let captureTarget = page;
204
+
205
+ if (opts.click) {
206
+ if (opts.popup) {
207
+ // context.waitForEvent('page') must be armed BEFORE the click, not after — the
208
+ // popup can open (and even finish loading) before an await on the click resolves.
209
+ const [popup] = await Promise.all([
210
+ context.waitForEvent('page', { timeout: 15_000 }),
211
+ page.click(opts.click),
212
+ ]);
213
+ await popup.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
214
+ captureTarget = popup;
215
+ } else {
216
+ await page.click(opts.click);
217
+ // Give in-page content (a modal, an inline export panel) a moment to render —
218
+ // there's no popup "page" event to wait on for same-page UI.
219
+ await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
220
+ }
221
+ }
222
+
223
+ await captureTarget.screenshot({ path: outputPath, fullPage: true });
224
+ if (captureTarget !== page) await captureTarget.close();
153
225
  } finally {
154
226
  await browser.close();
155
227
  }
@@ -259,21 +331,26 @@ export function escapeAttr(s) {
259
331
 
260
332
  /**
261
333
  * @param {object} opts
262
- * @param {"admin"|"messaging"} opts.app
334
+ * @param {"admin"|"messaging"|"livefluence"} opts.app
263
335
  * @param {string} opts.route
264
336
  * @param {number} opts.articleId
265
337
  * @param {string} [opts.heading]
266
338
  * @param {{x:number,y:number,label?:string}[]} [opts.annotations]
339
+ * @param {string} [opts.click] Selector to click after the route loads, before capturing.
340
+ * @param {boolean} [opts.popup] The click opens a new browser window — capture that
341
+ * window's content instead of the original page's. See loginAndScreenshot() for why
342
+ * this is required (a popup is invisible to a plain page.screenshot() otherwise).
267
343
  * @param {{token:string, apiBase:string}} [opts.auth] Explicit auth override for callers
268
344
  * that don't use the CLI's stored login config (e.g. mcp/server.js, which authenticates
269
345
  * via VELARO_MCP_KEY/VELARO_JWT env vars). CLI usage omits this and falls back to
270
346
  * getCredentials() (the normal `velaro login` flow).
271
347
  * @returns {Promise<{articleId:number, imageUrl:string, insertedAfterHeading:boolean}>}
272
348
  */
273
- export async function captureKbScreenshot({ app, route, articleId, heading, annotations, auth }) {
274
- if (!app || !APP_URLS[app]) throw new Error('app must be "admin" or "messaging"');
349
+ export async function captureKbScreenshot({ app, route, articleId, heading, annotations, click, popup, auth }) {
350
+ if (!app || !APP_URLS[app]) throw new Error('app must be "admin", "messaging", or "livefluence"');
275
351
  if (!route) throw new Error('route is required');
276
352
  if (!articleId) throw new Error('articleId is required');
353
+ if (popup && !click) throw new Error('--popup requires --click (nothing to click that would open one)');
277
354
 
278
355
  const rawPath = join(tmpdir(), `kb-screenshot-${articleId}-${Date.now()}.png`);
279
356
  let polishedPath = rawPath;
@@ -283,7 +360,7 @@ export async function captureKbScreenshot({ app, route, articleId, heading, anno
283
360
  ? { velaroToken: auth.token, adminApiBase: auth.apiBase }
284
361
  : await getCredentials();
285
362
 
286
- await loginAndScreenshot(app, route, rawPath);
363
+ await loginAndScreenshot(app, route, rawPath, { click, popup });
287
364
  polishedPath = await polishScreenshot(rawPath, annotations);
288
365
  const imageUrl = await uploadImage(polishedPath, creds);
289
366
 
@@ -0,0 +1,17 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { normalizeMcpKeysResponse } from './commands/mcp-key.js';
4
+
5
+ test('MCP key list reads the current API response shape', () => {
6
+ const keys = [{ id: 1, label: 'Staff', keyPrefix: 'vel_live_example' }];
7
+ assert.deepEqual(normalizeMcpKeysResponse({ keys, monthlyLimit: 100 }), keys);
8
+ });
9
+
10
+ test('MCP key list accepts the legacy array response', () => {
11
+ const keys = [{ id: 2, label: 'Legacy' }];
12
+ assert.deepEqual(normalizeMcpKeysResponse(keys), keys);
13
+ });
14
+
15
+ test('MCP key list rejects an unexpected response instead of reporting no keys', () => {
16
+ assert.throws(() => normalizeMcpKeysResponse({ error: 'unavailable' }), /Unexpected MCP API key list response/);
17
+ });