diff --git a/RELEASE-CHECKLIST.md b/RELEASE-CHECKLIST.md index 51b8823..2a83af1 100644 --- a/RELEASE-CHECKLIST.md +++ b/RELEASE-CHECKLIST.md @@ -133,3 +133,167 @@ before any submit work. Only a failed fetch blocks; the state value is not a gat in `--json`), and a thrown error is formatted by `withCommandHandler`. - [ ] Manual: point the CLI at an unreachable API and confirm `brevo app submit` exits non-zero with the status-fetch error, not a submit error. + +### Smoke test: public-app lifecycle (BEX-339) + +**Change:** `scripts/smoke-test.ts` rewritten around two lifecycles. Removed +`stepPublicAppRejected` (public create is valid since BEX-327). Replaced the +`brevo app update` step with `brevo app upload` steps, fixed the scaffold step +(no more `--app-id`), and added the public flow: create → upload → status → +submit → submit again → status → withdraw → status → delete, plus negative +probes. Every create now runs from a tracked tmp work root because `create` +writes `./` into the cwd. New `--skip-public` / `--with-public` flags; +gated commands are feature-detected from `brevo --help` and reported as +**skipped**, not failed. Test-only — no `src/` change, so no SKILL.md/AGENTS.md +update is required. + +**Must hold true:** + +- [x] `yarn smoke --help` lists `--with-public` / `--skip-public`; unknown flags + still exit 2 with the help text. +- [x] Script typechecks under the repo's strict settings + (`tsc --noEmit --strict --noUncheckedIndexedAccess`) and is prettier-clean. +- [x] Full step list passes end to end against a mock `brevo` on `PATH` + (25/25), and the mock account holds zero apps afterwards. +- [x] Capability gating: with `submit`/`status`/`withdraw`/`upload` absent from + `brevo --help`, the run stays green — 13 passed, 12 **skipped**, no + failures, both apps still deleted. This is the `--against=published` + path while sibling tickets land. +- [x] Pre-BEX-255 build (create returns no `directory`): upload / no-op upload / + verify-rename / scaffold / start skip themselves; 19 passed, 6 skipped. +- [x] Backend serves no `google_form_link`: the submit step skips with the app + id in the reason rather than failing; the repeat-submit step skips too. +- [x] Mid-run `SIGINT` (during "Start briefly"): exit 130, the created app is + deleted by the trap, and no `brevo-smoke-work-*` tmp dir is left behind. +- [x] `yarn lint` and `yarn test` (733 tests) pass — unchanged, since nothing + under `src/` is touched. +- [x] **Manual, real backend** — ran `yarn smoke --skip-auth` on 2026-07-29 + against a live account (prod API, OAuth login, local build via `yarn link`). + **24/25 passed;** the one failure was the private-app submit probe, which + surfaced a real CLI issue, now recorded in the PR description's *Reviewer + notes* (see the last bullet below). Every assertion that encoded a guess about server behaviour + is now confirmed: + - [x] `app-config.json`'s `distribution_type` comes back `public` for a public + app — round-trip via `buildTemplateVars` works, no silent `private`. + - [x] The second `upload` reported `up to date at version 0.0.2` — the server + does **not** bump `version` on an unchanged upload, so the strict + `upToDate: true` branch is the one that fires. + - [x] `submit` straight after `upload` was **not** rejected for config drift + (run from the project dir, so the drift check did execute). + - [x] `status` for a freshly created + uploaded public app returned + `configured` — a state the CLI has copy for, not `unknown`. + - [x] `withdraw` on a never-submitted app returned the mapped `NOT_SUBMITTED` + payload at exit 0 (HTTP 422, not a 404). + - [x] `status` **and** `withdraw` on a random UUID both mapped to not-found at + exit 5. + - [x] No `brevo-cli-smoke*` app left on the account — both delete steps assert + absence from `app list` after deleting, and both passed. + - [x] Bonus, unplanned: `submit` **did** return a review-form link on prod, so + the public path was exercised for real rather than skipped. The repeat + submit was idempotent (same URL, exit 0), confirming that branch too. +- [ ] Reviewer: confirm the two intentionally permissive assertions are the right + call — the repeat-submit probe accepts idempotent success or the mapped + "currently unavailable" refusal, because the CLI's submit hands over a form + URL rather than transitioning state, so a server-side "already submitted" + rejection can't be produced from the CLI alone; and the private-app submit + probe now accepts the server's `This activity is not supported for private + apps.` alongside the CLI's own `APP_SUBMIT_NOT_PUBLIC` copy, because the + status preflight in `submit.ts` fires first and makes the CLI's message + unreachable. The refusal is correct either way — but if the reviewer would + rather the CLI own that message, the fix is described in the PR's + *Reviewer notes*. + +### Smoke test: cleanup + rate-limit hardening (BEX-339 follow-up) + +**Change:** Three defects the second live run exposed, all in `scripts/smoke-test.ts`: + +1. `trapDeleteApps` logged `trap: deleted app ` without checking the exit + status — `spawnSync` doesn't throw on a non-zero exit, so a delete that 401'd + was recorded as a success and the orphan went unreported. It now checks + `r.status`, logs the real reason, and prints an `⚠ ORPHANED APPS` block with + the delete commands. +2. `Logout` and `Final cleanup` ran as ordinary steps *before* the post-run + safety net, destroying the credentials and the linked binary it needed — so a + leftover app could never be recovered. Added a `Cleanup: leftover apps` step + ahead of them. +3. A rate-limited API failed every later step, including making the negative + probes assert mapped messages against `Rate limited. Retrying in 5 seconds…`. + `exec()` now retries centrally (5s/15s/30s) when a *failed* call looks + rate-limited, and counts the waits. + +Leaks and throttling are now visible in the summary and the `--report=` JSON +(`orphanedAppIds`, `rateLimitWaits`). + +**Must hold true:** + +- [x] Transient rate limit on one call → absorbed: one 5s wait, step passes, run + green, `rateLimitWaits: 1` in the report. +- [x] Every `app delete` failing → run fails, `LEAKED 2 app(s)` in the summary, + both ids in `orphanedAppIds`, orphan block printed with delete commands, + and the ids really are still on the (mock) account — report matches reality. +- [x] Trap log never claims an unverified delete: `trap: FAILED to delete app + (exit 3): `. +- [x] No regression: clean run 26/26; gated run 14 passed / 12 skipped; both + self-cleaning. Typecheck + prettier clean. +- [x] Sonar: 7 code smells in `scripts/smoke-test.ts` fixed (S8786 regex + backtracking → line-based stack-frame detector, S3358 ×2, S4624, S6551, + S7776, S1135). Zero security hotspots. The other 7 findings on the PR are + pre-existing in `src/` files this branch doesn't touch. +- [ ] **Live re-run still pending.** The fixes above are verified against a mock + `brevo` only. Re-run `yarn smoke --skip-auth` on a real account to confirm + end to end — ideally against staging rather than a shared prod account, + which is what throttled the last run and made the orphan real. +- [ ] Clean up after the pre-fix run: `brevo app list` and delete anything named + `brevo-cli-smoke*` (`brevo app delete --app-id --force`). That run's + public-app delete was rate-limited and the trap's "deleted" line was the + unverified log fixed in point 1, so one may still exist. App ids aren't + recorded here — this repo is public. + +### Smoke test: split into per-flow suite modules (BEX-339 follow-up) + +**Change:** `scripts/smoke-test.ts` was one 2141-line file. Split so either +lifecycle can run on its own: + +| File | Role | +| --- | --- | +| `scripts/smoke-test.ts` | Runner — flags, suite registry, step composition, summary, report | +| `scripts/smoke/core.ts` | Shared plumbing: state, logging, exec + rate-limit retry, assertions, capability detection, create/upload/delete helpers, teardown, traps | +| `scripts/smoke/private-app.ts` | `privateAppSuite` | +| `scripts/smoke/public-app.ts` | `publicAppSuite` | +| `scripts/smoke/init-wizard.ts` | `initWizardSuite` (opt-in) | + +Selection is `--suite=private|public|init|all` (comma-separated, default +`private,public`). `--with-public` / `--skip-public` / `--with-init` are kept as +aliases. Setup (pre-flight, install, auth) and teardown (leftover-app cleanup, +logout, uninstall) always run, so each suite stands alone — the public suite +creates its own app and never depends on the private one. + +The extraction was mechanical: all 127 top-level blocks were indexed and +verified to be covered exactly once (no gaps, no overlaps) before reassembly, so +no step logic changed in the move. + +**Must hold true:** + +- [x] Typecheck (`--strict --noUncheckedIndexedAccess`) and prettier clean across + all five files. +- [x] `--suite=private` → 16 steps, `--suite=public` → 16, default → 26, + `--skip-public` → 16. All pass, all self-cleaning. +- [x] `--suite=frobnicate` is rejected, listing the valid names. +- [x] Public suite alone against a build without the review commands: + 8 passed / 8 skipped, still green. +- [x] Failure modes survive the split: gated build 14 passed / 12 skipped; + every-delete-failing still reports `ORPHANED APPS` + `LEAKED 2 app(s)`; + transient rate limit still absorbed with one 5s wait. +- [x] **Live run, real account, correct binary** — 26/26. Step 2 reported + `brevo 2.0.1 at ~/.yarn/bin/brevo`, matching `package.json`, so this + exercised the branch build. Observed: upload bumped to version `0.0.2`; + no-op upload reported up-to-date; public status `configured` throughout; + submit returned a review form URL and the repeat submit was idempotent; + withdraw mapped to `NOT_SUBMITTED` at exit 0; unknown app id → exit 5 for + both `status` and `withdraw`; account left at its baseline app count. +- [ ] **Do not run this suite via `yarn smoke` until the version guard lands** + (see the PR's *Reviewer notes*). yarn prepends `node_modules/.bin` ahead of any exported + PATH, and this repo currently has a stray undeclared `@dtsl/brevo-cli` + symlinked there. An earlier live run passed 26/26 against *that* package + instead of the branch build. Invoke it directly meanwhile: + `PATH="$HOME/.yarn/bin:$PATH" ./node_modules/.bin/tsx scripts/smoke-test.ts --skip-auth` diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts index e4ca711..0fcca6e 100644 --- a/scripts/smoke-test.ts +++ b/scripts/smoke-test.ts @@ -1,36 +1,85 @@ #!/usr/bin/env node /* - * Smoke test for @getbrevo/cli. + * Smoke test for @getbrevo/cli — runner. + * + * Setup (pre-flight, install, auth) and teardown (leftover-app cleanup, logout, + * uninstall) always run. In between it runs whichever suites `--suite` selects: + * + * scripts/smoke/private-app.ts create → credentials → upload → verify rename + * → scaffold → start → delete, + guardrail probes + * scripts/smoke/public-app.ts create → upload → status → submit → submit again + * → status → withdraw → status → delete + * scripts/smoke/init-wizard.ts the `brevo app init` wizard (opt-in) + * + * Shared plumbing lives in scripts/smoke/core.ts. + * + * Every app a run creates is tracked on `State` so the cleanup step and the + * signal traps tear it down on success, on failure, and on SIGINT/SIGTERM. + * Every directory written lives under a tracked tmp dir — `brevo app create` + * creates `./` in the cwd, so creates never run from the repo root. */ -import { spawn, spawnSync, ChildProcess } from 'node:child_process'; -import { - appendFileSync, - closeSync, - existsSync, - mkdtempSync, - openSync, - readFileSync, - readdirSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import * as http from 'node:http'; -import * as net from 'node:net'; -import { tmpdir } from 'node:os'; +import { closeSync, openSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { tmpdir } from 'node:os'; -// ──────────────────────────── options ──────────────────────────── +import { + COLOR, + Options, + SkippedStep, + State, + StepFn, + Suite, + announce, + bestEffortCleanup, + errMsg, + formatMs, + logToFile, + pickFreePort, + redact, + stepAuth, + stepDeleteLeftoverApps, + stepDone, + stepFinalCleanup, + stepLogout, + stepPreflight, + stepReinstall, +} from './smoke/core'; +import { privateAppSuite } from './smoke/private-app'; +import { publicAppSuite } from './smoke/public-app'; +import { initWizardSuite } from './smoke/init-wizard'; + +// Suite registry. `--suite=` picks from these; the init wizard is +// opt-in because it drives interactive prompts through scripted stdin. +const SUITES: Record = { + private: privateAppSuite, + public: publicAppSuite, + init: initWizardSuite, +}; -interface Options { - skipAuth: boolean; - verbose: boolean; - port: number; - portExplicit: boolean; - reportPath: string | null; - ci: boolean; - against: 'local' | 'published'; - withInit: boolean; +const DEFAULT_SUITES = ['private', 'public']; + +function uniq(values: string[]): string[] { + return [...new Set(values)]; +} + +function parseSuiteValue(arg: string): string[] { + const raw = arg.slice('--suite='.length); + if (raw === 'all') return Object.keys(SUITES); + const names = uniq( + raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ); + if (names.length === 0) throw new Error('--suite needs at least one suite name'); + const unknown = names.filter((n) => !SUITES[n]); + if (unknown.length > 0) { + throw new Error( + `unknown suite(s): ${unknown.join(', ')} — valid: ${Object.keys(SUITES).join(', ')}, all`, + ); + } + return names; } function parsePortValue(arg: string): number { @@ -49,6 +98,36 @@ function parseAgainstValue(arg: string): 'local' | 'published' { return v; } +// Split in two so neither half trips Sonar's cognitive-complexity limit, and so +// adding a flag means touching exactly one of them. +function applyBooleanFlag(opts: Options, arg: string): boolean { + if (arg === '--skip-auth') opts.skipAuth = true; + else if (arg === '--verbose') opts.verbose = true; + else if (arg === '--ci') { + opts.ci = true; + opts.verbose = true; + } else if (arg === '--with-init') opts.suites = uniq([...opts.suites, 'init']); + // Kept as aliases for --suite so existing invocations keep working. + else if (arg === '--with-public') opts.suites = uniq([...opts.suites, 'public']); + else if (arg === '--skip-public') opts.suites = opts.suites.filter((s) => s !== 'public'); + else return false; + return true; +} + +function applyValueFlag(opts: Options, arg: string): boolean { + if (arg.startsWith('--port=')) { + opts.port = parsePortValue(arg); + opts.portExplicit = true; + } else if (arg.startsWith('--report=')) { + opts.reportPath = arg.slice('--report='.length); + } else if (arg.startsWith('--against=')) { + opts.against = parseAgainstValue(arg); + } else if (arg.startsWith('--suite=')) { + opts.suites = parseSuiteValue(arg); + } else return false; + return true; +} + function parseArgs(argv: string[]): Options { const opts: Options = { skipAuth: false, @@ -58,29 +137,18 @@ function parseArgs(argv: string[]): Options { reportPath: null, ci: false, against: 'local', - withInit: false, + suites: [...DEFAULT_SUITES], }; for (const arg of argv) { - if (arg === '--skip-auth') opts.skipAuth = true; - else if (arg === '--verbose') opts.verbose = true; - else if (arg === '--with-init') opts.withInit = true; - else if (arg === '--ci') { - opts.ci = true; - opts.verbose = true; - } else if (arg.startsWith('--port=')) { - opts.port = parsePortValue(arg); - opts.portExplicit = true; - } else if (arg.startsWith('--report=')) { - opts.reportPath = arg.slice('--report='.length); - } else if (arg.startsWith('--against=')) { - opts.against = parseAgainstValue(arg); - } else if (arg === '-h' || arg === '--help') { + if (arg === '-h' || arg === '--help') { printHelp(); process.exit(0); - } else { + } + if (!applyBooleanFlag(opts, arg) && !applyValueFlag(opts, arg)) { throw new Error(`unknown flag: ${arg}`); } } + if (opts.suites.length === 0) throw new Error('no suites selected'); if (opts.ci && !opts.skipAuth && !process.env.BREVO_API_KEY) { throw new Error('--ci requires BREVO_API_KEY in env (or pair with --skip-auth)'); } @@ -97,322 +165,32 @@ Flags: --report= Write JSON run summary to . --ci CI mode: API-key auth via BREVO_API_KEY (instead of browser). --against=local|published Install strategy (default local). - --with-init Also exercise the 'brevo app init' wizard (skipped by default). + --suite= Which suites to run, comma-separated, in order. + private private-app lifecycle + client guardrails + public public-app submission/review lifecycle + init 'brevo app init' wizard (interactive, opt-in) + all every suite + Default: private,public + --with-init Append the init suite (same as adding 'init'). + --with-public Append the public suite. + --skip-public Drop the public suite (same as --suite=private). -h, --help Show this help. -`); -} -// ──────────────────────────── state ──────────────────────────── - -interface StepResult { - name: string; - ok: boolean; - durationMs: number; - error?: string; -} - -interface State { - opts: Options; - logFile: string; - logFd: number; - mainAppId: string | null; - initAppId: string | null; - mainTmpDir: string | null; - mainScaffoldDir: string | null; - initTmpDir: string | null; - linked: boolean; - startChild: ChildProcess | null; - stepResults: StepResult[]; -} +Setup (pre-flight, install, auth) and teardown (leftover-app cleanup, logout, +uninstall) always run, whichever suites are selected. Examples: -// ──────────────────────────── logging ──────────────────────────── - -// Strip values that look like Brevo secrets before any line hits the log file, -// since this log is what gets uploaded as a CI artefact in Phase 2. -function redact(s: string): string { - return s - .replaceAll(/xkeysib-[A-Za-z0-9_-]+/g, 'xkeysib-***REDACTED***') - .replaceAll(/"clientSecret"\s*:\s*"[^"]+"/g, '"clientSecret":"***REDACTED***"') - .replaceAll(/"client_secret"\s*:\s*"[^"]+"/g, '"client_secret":"***REDACTED***"'); -} - -function logToFile(state: State, line: string): void { - appendFileSync(state.logFd, `${new Date().toISOString()} ${redact(line)}\n`); -} - -function announce(state: State, n: number, title: string): void { - const line = `\n▶ Step ${n}: ${title}`; - process.stdout.write(line + '\n'); - logToFile(state, line); -} - -function stepDone(state: State, ok: boolean, detail: string, ms: number): void { - const icon = ok ? '✓' : '✗'; - const line = ` ${icon} ${detail} — ${ok ? 'ok' : 'FAILED'} (${formatMs(ms)})`; - process.stdout.write(line + '\n'); - logToFile(state, line); -} - -function errMsg(e: unknown): string { - return e instanceof Error ? e.message : String(e); -} - -function formatMs(ms: number): string { - return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`; -} + yarn smoke --skip-auth --suite=private # private lifecycle only + yarn smoke --skip-auth --suite=public # public review lifecycle only + yarn smoke --ci --suite=private,public # both (the default) -// ──────────────────────────── colour ──────────────────────────── - -// Honour NO_COLOR and non-TTY stdout so CI logs stay clean. -const COLOR_ENABLED = !process.env.NO_COLOR && Boolean(process.stdout.isTTY); - -const COLOR = { - reset: COLOR_ENABLED ? '\x1b[0m' : '', - bold: COLOR_ENABLED ? '\x1b[1m' : '', - dim: COLOR_ENABLED ? '\x1b[2m' : '', - red: COLOR_ENABLED ? '\x1b[31m' : '', - green: COLOR_ENABLED ? '\x1b[32m' : '', - yellow: COLOR_ENABLED ? '\x1b[33m' : '', - cyan: COLOR_ENABLED ? '\x1b[36m' : '', -}; - -// ──────────────────────────── subprocess helpers ──────────────────────────── - -interface ExecOptions { - cwd?: string; - input?: string; - inherit?: boolean; -} - -interface ExecResult { - stdout: string; - stderr: string; - exitCode: number; -} - -function exec(cmd: string, args: string[], state: State, opts: ExecOptions = {}): ExecResult { - const pretty = `$ ${cmd} ${args.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')}`; - logToFile(state, pretty); - if (state.opts.verbose) process.stdout.write(` ${pretty}\n`); - - const result = spawnSync(cmd, args, { - cwd: opts.cwd, - input: opts.input, - encoding: 'utf8', - env: process.env, - stdio: opts.inherit ? 'inherit' : ['pipe', 'pipe', 'pipe'], - }); - const stdout = result.stdout ?? ''; - const stderr = result.stderr ?? ''; - const exitCode = result.status ?? -1; - - if (!opts.inherit) { - if (stdout) logToFile(state, stdout.trimEnd()); - if (stderr) logToFile(state, '[stderr] ' + stderr.trimEnd()); - if (state.opts.verbose) { - if (stdout) process.stdout.write(stdout); - if (stderr) process.stderr.write(stderr); - } - } - return { stdout, stderr, exitCode }; -} - -function execOrThrow( - cmd: string, - args: string[], - state: State, - opts: ExecOptions = {}, -): ExecResult { - const r = exec(cmd, args, state, opts); - if (r.exitCode !== 0) { - throw new Error( - `${cmd} ${args.join(' ')} exited ${r.exitCode}: ${(r.stderr || r.stdout).trim().split('\n')[0]}`, - ); - } - return r; -} - -// Drive a child process by writing scripted answers to its stdin with a small -// delay between lines. `spawnSync` with `input:` closes stdin as soon as the -// buffer is written, which trips up readline-based prompt libraries (inquirer -// in particular) — they see EOF before the first prompt is rendered and fall -// back to defaults. Paced writes give the prompt loop time to read each line. -async function execScriptedStdin( - cmd: string, - args: string[], - state: State, - opts: { cwd?: string; answers: string[]; interLineDelayMs?: number }, -): Promise<{ stdout: string; exitCode: number }> { - const pretty = `$ ${cmd} ${args.join(' ')} (scripted stdin)`; - logToFile(state, pretty); - return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { - cwd: opts.cwd, - stdio: ['pipe', 'pipe', 'pipe'], - env: process.env, - }); - let buf = ''; - const onData = (d: Buffer) => { - const s = d.toString(); - buf += s; - logToFile(state, s.trimEnd()); - }; - child.stdout?.on('data', onData); - child.stderr?.on('data', onData); - child.on('error', reject); - child.on('exit', (code) => resolve({ stdout: buf, exitCode: code ?? -1 })); - const delay = opts.interLineDelayMs ?? 250; - (async () => { - for (const line of opts.answers) { - await sleep(delay); - if (!child.stdin || child.stdin.destroyed) break; - child.stdin.write(line + '\n'); - } - await sleep(delay); - child.stdin?.end(); - })().catch((e) => logToFile(state, `stdin writer error: ${e}`)); - }); -} - -// Run a child process while letting the user see (and respond to) its output -// in real time. stdin is inherited so the user can answer interactive prompts; -// stdout/stderr are tee'd to terminal AND captured into a buffer for parsing -// (e.g. extracting "App ID: " from the init wizard). -function execStreaming( - cmd: string, - args: string[], - state: State, - opts: { cwd?: string } = {}, -): Promise<{ stdout: string; exitCode: number }> { - const pretty = `$ ${cmd} ${args.join(' ')}`; - logToFile(state, pretty); - return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { - cwd: opts.cwd, - stdio: ['inherit', 'pipe', 'pipe'], - env: process.env, - }); - let buf = ''; - const onData = (d: Buffer) => { - const s = d.toString(); - buf += s; - process.stdout.write(s); - logToFile(state, s.trimEnd()); - }; - child.stdout?.on('data', onData); - child.stderr?.on('data', onData); - child.on('error', reject); - child.on('exit', (code) => resolve({ stdout: buf, exitCode: code ?? -1 })); - }); -} - -// The /v3/oauth/apps list endpoint is eventually consistent with create/delete -// (documented in src/commands/app/list.ts). Poll a few times before deciding -// an app is missing or still present. -async function findAppInList( - state: State, - expectedId: string, - shouldBePresent: boolean, - attempts = 4, -): Promise { - const backoff = [500, 1000, 2000, 4000]; - for (let i = 0; i < attempts; i++) { - const r = execOrThrow('brevo', ['app', 'list', '--json'], state); - const ids = collectAppIds(parseJson(r.stdout)); - if (ids.has(expectedId) === shouldBePresent) return true; - if (i < attempts - 1) await sleep(backoff[i] ?? 4000); - } - return false; -} - -function parseJson(stdout: string): T { - // brevo sometimes prints a spinner/banner before --json output, so scan to the first { or [. - const idx = stdout.search(/[{[]/); - if (idx < 0) throw new Error(`no JSON in output: ${stdout.slice(0, 200)}`); - return JSON.parse(stdout.slice(idx)); -} - -function collectAppIds(listJson: unknown): Set { - const items = Array.isArray(listJson) - ? listJson - : ((listJson as { apps?: unknown[]; data?: unknown[] })?.apps ?? - (listJson as { data?: unknown[] })?.data ?? - []); - const ids = new Set(); - for (const item of items as Array>) { - // `brevo app list --json` returns `app_id` (snake_case, per src/types.ts). - // `brevo app create --json` returns `appId` (camelCase). Some endpoints use - // plain `id`. We accept all three so comparisons work across boundaries. - const id = item.app_id ?? item.appId ?? item.id; - if (typeof id === 'string' || typeof id === 'number') ids.add(String(id)); - } - return ids; -} - -// ──────────────────────────── port helpers ──────────────────────────── - -function isPortFree(port: number): Promise { - return new Promise((resolve) => { - const srv = net.createServer(); - srv.once('error', () => resolve(false)); - srv.once('listening', () => srv.close(() => resolve(true))); - srv.listen(port); - }); -} - -async function assertPortFree(port: number): Promise { - if (!(await isPortFree(port))) throw new Error(`port ${port} already in use`); -} - -async function pickFreePort(start: number, range = 50): Promise { - for (let port = start; port < start + range; port++) { - if (await isPortFree(port)) return port; - } - throw new Error(`no free port found in [${start}, ${start + range})`); -} - -function probeHttp(port: number): Promise { - return new Promise((resolve) => { - const req = http.get({ host: '127.0.0.1', port, path: '/', timeout: 1000 }, (res) => { - res.resume(); - resolve(true); - }); - req.on('error', () => resolve(false)); - req.on('timeout', () => { - req.destroy(); - resolve(false); - }); - }); -} - -function sleep(ms: number): Promise { - return new Promise((r) => setTimeout(r, ms)); -} - -function waitForExit(child: ChildProcess, timeoutMs: number): Promise { - return new Promise((resolve) => { - if (child.exitCode !== null) { - resolve(); - return; - } - const t = setTimeout(() => { - try { - child.kill('SIGKILL'); - } catch { - // ignore - } - resolve(); - }, timeoutMs); - child.once('exit', () => { - clearTimeout(t); - resolve(); - }); - }); +Steps that need a command the installed CLI doesn't have (notably +--against=published, where 'app submit' / 'app status' / 'app withdraw' / +'app upload' may not be released yet) are auto-detected and reported as +skipped rather than failed. +`); } -// ──────────────────────────── steps ──────────────────────────── - -type StepFn = (state: State) => Promise | string; +// ──────────────────────────── state ──────────────────────────── async function runStep(n: number, name: string, fn: StepFn, state: State): Promise { announce(state, n, name); @@ -420,643 +198,24 @@ async function runStep(n: number, name: string, fn: StepFn, state: State): Promi try { const detail = (await fn(state)) || 'done'; const ms = Date.now() - t0; - state.stepResults.push({ name, ok: true, durationMs: ms }); - stepDone(state, true, detail, ms); + state.stepResults.push({ name, ok: true, durationMs: ms, detail }); + stepDone(state, 'ok', detail, ms); return true; } catch (err) { const ms = Date.now() - t0; - const message = err instanceof Error ? err.message : String(err); + const message = errMsg(err); + if (err instanceof SkippedStep) { + state.stepResults.push({ name, ok: true, skipped: true, durationMs: ms, detail: message }); + stepDone(state, 'skipped', message, ms); + return true; + } state.stepResults.push({ name, ok: false, durationMs: ms, error: message }); - stepDone(state, false, (message.split('\n')[0] ?? message).slice(0, 200), ms); + stepDone(state, 'failed', (message.split('\n')[0] ?? message).slice(0, 200), ms); logToFile(state, message); return false; } } -function stepPreflight(state: State): string { - const node = execOrThrow('node', ['-v'], state).stdout.trim(); - const yarn = execOrThrow('yarn', ['-v'], state).stdout.trim(); - return `node ${node}, yarn ${yarn}, against=${state.opts.against}, ci=${state.opts.ci}`; -} - -function stepReinstall(state: State): string { - // Tolerate errors here — prior installations may not exist. - exec('yarn', ['unlink'], state); - exec('npm', ['uninstall', '-g', '@getbrevo/cli'], state); - - if (state.opts.against === 'local') { - execOrThrow('yarn', ['build'], state); - execOrThrow('yarn', ['link'], state); - } else { - execOrThrow('npm', ['install', '-g', '@getbrevo/cli@latest'], state); - } - state.linked = true; - - const which = execOrThrow('which', ['brevo'], state).stdout.trim(); - const version = execOrThrow('brevo', ['--version'], state).stdout.trim(); - return `brevo ${version} at ${which}`; -} - -async function stepAuth(state: State): Promise { - if (state.opts.skipAuth) { - const r = execOrThrow('brevo', ['whoami', '--json'], state); - parseJson(r.stdout); - return 'already authenticated (--skip-auth)'; - } - - exec('brevo', ['logout', '--force', '--json'], state); - - if (state.opts.ci) { - // brevo login picks up BREVO_API_KEY from env automatically. - execOrThrow('brevo', ['login', '--json'], state); - } else { - process.stdout.write(` ${COLOR.cyan}⏳ waiting for browser login...${COLOR.reset}\n`); - // --json short-circuits the post-login "Would you like to create an app?" - // prompt (see src/commands/login.ts) that would otherwise block the smoke - // run when the account has zero apps. The smoke test creates its own app - // in stepAppLifecycle, so that prompt is never useful here. - // - // Trade-off: --json also suppresses the browser-fallback URL. If your - // browser doesn't auto-open, the run will appear to hang. Run the login - // manually first (`brevo login`) then use `yarn smoke --skip-auth`. - const r = await execStreaming('brevo', ['login', '--json'], state); - if (r.exitCode !== 0) throw new Error('brevo login failed'); - } - - const whoami = execOrThrow('brevo', ['whoami', '--json'], state); - parseJson(whoami.stdout); - return 'logged in'; -} - -// State threaded between the four app-lifecycle steps. mainAppId lives on -// State (used by later steps too); the rest is step-to-step plumbing. -interface AppContext { - name: string; - redirectUri: string; - renamedTo: string; - extraRedirectUri: string; -} - -let appCtx: AppContext | null = null; - -async function stepAppCreate(state: State): Promise { - // Readable, traceable name. Concurrent CI runs are namespaced by GH run id. - const stamp = state.opts.ci - ? `${process.env.GITHUB_RUN_ID || Date.now()}-${process.env.GITHUB_RUN_ATTEMPT || '1'}` - : String(Date.now()); - const name = `brevo-cli-smoke-test-${stamp}`; - const redirectUri = `http://localhost:${state.opts.port}/auth/callback`; - appCtx = { - name, - redirectUri, - renamedTo: `${name}-renamed`, - extraRedirectUri: 'https://example.com/cb', - }; - - const create = execOrThrow( - 'brevo', - [ - 'app', - 'create', - '--name', - name, - '--distribution', - 'private', - '--redirect-uri', - redirectUri, - '--json', - ], - state, - ); - const created = parseJson>(create.stdout); - const rawAppId = created.id ?? created.appId; - const appId = - typeof rawAppId === 'string' || typeof rawAppId === 'number' ? String(rawAppId) : ''; - if (!appId) throw new Error(`no app id in create output: ${create.stdout.slice(0, 200)}`); - state.mainAppId = appId; - - // List endpoint lags create — retry with backoff before declaring missing. - if (!(await findAppInList(state, appId, true))) { - throw new Error(`app ${appId} not present in list after create (after retries)`); - } - - return `app ${appId} created + listed`; -} - -function stepAppCredentials(state: State): string { - if (!state.mainAppId) throw new Error('no mainAppId from create step'); - const creds = execOrThrow( - 'brevo', - ['app', 'credentials', '--app-id', state.mainAppId, '--reveal-secret', '--json'], - state, - ); - const credObj = parseJson>(creds.stdout); - if (!credObj.clientId || !credObj.clientSecret) { - throw new Error('credentials response missing clientId or clientSecret'); - } - return `clientId + clientSecret returned`; -} - -function stepAppUpdate(state: State): string { - if (!state.mainAppId) throw new Error('no mainAppId from create step'); - if (!appCtx) throw new Error('no appCtx from create step'); - const appId = state.mainAppId; - const { redirectUri, renamedTo, extraRedirectUri } = appCtx; - - const updated = execOrThrow( - 'brevo', - [ - 'app', - 'update', - '--app-id', - appId, - '--name', - renamedTo, - '--redirect-uri', - extraRedirectUri, - '--yes', - '--json', - ], - state, - ); - const updatedJson = parseJson>(updated.stdout); - - const rawUpdatedId = updatedJson.app_id; - const updatedId = - typeof rawUpdatedId === 'string' || typeof rawUpdatedId === 'number' - ? String(rawUpdatedId) - : ''; - if (updatedId !== appId) { - throw new Error(`update returned app_id ${JSON.stringify(rawUpdatedId)}, expected ${appId}`); - } - if (updatedJson.name !== renamedTo) { - throw new Error( - `update returned name ${JSON.stringify(updatedJson.name)}, expected ${renamedTo}`, - ); - } - const updatedUris = updatedJson.redirect_uris; - if (!Array.isArray(updatedUris)) { - throw new TypeError(`update redirect_uris is not an array: ${JSON.stringify(updatedUris)}`); - } - // --redirect-uri appends (see CLAUDE.md + src/commands/app/update.ts:186-194): - // the create-time URI must survive, and the new one must be present. - if (!updatedUris.includes(redirectUri)) { - throw new Error( - `update redirect_uris missing original ${redirectUri}: ${JSON.stringify(updatedUris)}`, - ); - } - if (!updatedUris.includes(extraRedirectUri)) { - throw new Error( - `update redirect_uris missing appended ${extraRedirectUri}: ${JSON.stringify(updatedUris)}`, - ); - } - - return `renamed + redirect_uri appended (response validated)`; -} - -async function stepVerifyRename(state: State): Promise { - if (!state.mainAppId) throw new Error('no mainAppId from create step'); - if (!appCtx) throw new Error('no appCtx from create step'); - const appId = state.mainAppId; - const { renamedTo } = appCtx; - - // Confirm the rename persisted server-side. The list endpoint is eventually - // consistent (see findAppInList), so poll with backoff before declaring miss. - const renameBackoff = [500, 1000, 2000, 4000]; - for (let i = 0; i < renameBackoff.length; i++) { - const r = execOrThrow('brevo', ['app', 'list', '--json'], state); - if (findAppByName(parseJson(r.stdout), renamedTo) === appId) { - return `rename visible in list as "${renamedTo}"`; - } - if (i < renameBackoff.length - 1) await sleep(renameBackoff[i] ?? 4000); - } - throw new Error( - `renamed app ${appId} (${renamedTo}) not present in list after update (after retries)`, - ); -} - -// Negative test: `brevo app create --distribution public` must be rejected by -// the CLI itself, *before* any API call is made (see src/commands/app/create.ts). -// A successful exit here would mean the CLI silently created a public app on -// the user's account — that's a real security regression, so we attempt to -// clean up if it ever happens. -function stepPublicAppRejected(state: State): string { - const probeName = `brevo-cli-smoke-public-reject-${Date.now()}`; - const result = exec( - 'brevo', - [ - 'app', - 'create', - '--name', - probeName, - '--distribution', - 'public', - '--redirect-uri', - `http://localhost:${state.opts.port}/auth/callback`, - '--json', - ], - state, - ); - - if (result.exitCode === 0) { - // Unexpected success — the CLI may have just created a public app. Try - // to identify and delete it so we don't leak. - try { - const obj = parseJson>(result.stdout); - const rawId = obj.appId ?? obj.app_id ?? obj.id; - const id = typeof rawId === 'string' || typeof rawId === 'number' ? String(rawId) : ''; - if (id) { - logToFile(state, `unexpected public-app creation: ${id} — attempting cleanup`); - spawnSync('brevo', ['app', 'delete', '--app-id', id, '--force', '--json'], { - timeout: 30_000, - }); - } - } catch { - // ignore parse failures — error path is what matters - } - throw new Error( - `brevo app create --distribution public was NOT rejected (exit 0); CLI may have created a public app`, - ); - } - - // Confirm the rejection came from the expected guard. The message lives in - // src/lang/en.ts (APP_CREATE_PUBLIC_UNAVAILABLE) and starts with "Public". - const errText = (result.stderr + result.stdout).toLowerCase(); - if (!errText.includes('public')) { - throw new Error( - `public-app create was rejected, but error text did not mention "public": ${(result.stderr || result.stdout).slice(0, 200)}`, - ); - } - - return `CLI rejected --distribution public (exit ${result.exitCode})`; -} - -function stepScaffold(state: State): string { - if (!state.mainAppId) throw new Error('no mainAppId from previous step'); - const tmp = mkdtempSync(join(tmpdir(), 'brevo-smoke-')); - state.mainTmpDir = tmp; - - // `brevo app scaffold` prompts inquirer for output dir; feed "\n" so the - // default (`./`) is accepted under piped stdin. - const result = execOrThrow( - 'brevo', - ['app', 'scaffold', '--app-id', state.mainAppId, '--json'], - state, - { cwd: tmp, input: '\n' }, - ); - - // The --json output of scaffold includes the resolved target directory. - const candidates = ['package.json', '.env.example', '.env', 'app-config.json', 'README.md']; - const dirsToCheck: string[] = []; - try { - const parsed = parseJson>(result.stdout); - if (typeof parsed.directory === 'string') dirsToCheck.push(parsed.directory); - } catch { - // fall through to subdir scan - } - dirsToCheck.push(tmp); - - for (const d of dirsToCheck) { - const found = candidates.filter((f) => existsSync(join(d, f))); - if (found.length > 0) { - state.mainScaffoldDir = d; - return `scaffolded into ${d} (${found.join(', ')})`; - } - } - - // Last resort: any subdir of tmp that contains a candidate file. - try { - const entries = readdirSync(tmp, { withFileTypes: true }); - for (const e of entries) { - if (!e.isDirectory()) continue; - const sub = join(tmp, e.name); - const found = candidates.filter((f) => existsSync(join(sub, f))); - if (found.length > 0) { - state.mainScaffoldDir = sub; - return `scaffolded into ${sub} (${found.join(', ')})`; - } - } - } catch { - // ignore - } - throw new Error(`no expected scaffold files in ${tmp} or its subdirectories`); -} - -async function stepStartBriefly(state: State): Promise { - // `brevo app start oauth` reads app-config.json from cwd, so we must run it - // from inside the scaffolded subdirectory, not the parent tmp dir. - const dir = state.mainScaffoldDir ?? state.mainTmpDir; - if (!dir) throw new Error('no scaffold dir from previous step'); - await assertPortFree(state.opts.port); - - // The scaffold template puts a per-feature package.json inside src/oauth/ - // (see src/templates/index.ts). `brevo app start oauth` rejects with - // "Dependencies not installed" unless node_modules exists there, so we run - // yarn install in both the project root and the feature subdir. - execOrThrow('yarn', ['install'], state, { cwd: dir }); - const featureDir = join(dir, 'src', 'oauth'); - if (existsSync(join(featureDir, 'package.json'))) { - execOrThrow('yarn', ['install'], state, { cwd: featureDir }); - } - - const child = spawn('brevo', ['app', 'start', 'oauth', '--port', String(state.opts.port)], { - cwd: dir, - stdio: ['ignore', 'pipe', 'pipe'], - env: process.env, - }); - state.startChild = child; - let lastOutput = ''; - let earlyExit: number | null = null; - child.stdout?.on('data', (d) => { - lastOutput += d.toString(); - logToFile(state, '[start] ' + d.toString().trimEnd()); - }); - child.stderr?.on('data', (d) => { - lastOutput += d.toString(); - logToFile(state, '[start-err] ' + d.toString().trimEnd()); - }); - child.on('exit', (code) => { - earlyExit = code; - }); - - // Poll for the server, but bail out early if the child has already exited - // (e.g. missing app-config.json, port conflict surfaced inside the child). - const timeoutMs = state.opts.ci ? 5000 : 10000; - const deadline = Date.now() + timeoutMs; - let ok = false; - while (Date.now() < deadline) { - if (earlyExit !== null) break; - if (await probeHttp(state.opts.port)) { - ok = true; - break; - } - await sleep(250); - } - if (earlyExit === null) child.kill('SIGTERM'); - await waitForExit(child, 3000); - state.startChild = null; - - if (!ok) { - const tail = lastOutput.trim().split('\n').slice(-3).join(' | '); - const cause = - earlyExit === null - ? `server did not respond on port ${state.opts.port} within ${timeoutMs}ms` - : `child exited ${earlyExit} before serving: ${tail}`; - throw new Error(cause); - } - return `server booted on port ${state.opts.port}`; -} - -async function stepDeleteMainApp(state: State): Promise { - if (!state.mainAppId) throw new Error('no mainAppId to delete'); - const id = state.mainAppId; - execOrThrow('brevo', ['app', 'delete', '--app-id', id, '--force', '--json'], state); - - // List lags delete too — retry until the app is gone. - if (!(await findAppInList(state, id, false))) { - throw new Error(`app ${id} still present after delete (after retries)`); - } - state.mainAppId = null; - return `app ${id} deleted`; -} - -// Secondary appId recovery: if our unique name made it through, the app is -// identifiable even without parsing wizard output. Retry to absorb -// list-endpoint propagation lag. -async function findInitAppByName(state: State, expectedName: string): Promise { - for (let attempt = 0; attempt < 4; attempt++) { - const after = execOrThrow('brevo', ['app', 'list', '--json'], state); - const found = findAppByName(parseJson(after.stdout), expectedName); - if (found) return found; - if (attempt < 3) await sleep([500, 1000, 2000][attempt] ?? 2000); - } - return null; -} - -// Tertiary appId recovery: read app-config.json (only present if user -// scaffolded in wizard, which our scripted answers explicitly decline — but -// keep as a safety net in case the wizard flow changes). -function readInitAppIdFromConfig(state: State, tmp: string): string | null { - const cfgPath = join(tmp, 'app-config.json'); - if (!existsSync(cfgPath)) return null; - try { - const cfg = JSON.parse(readFileSync(cfgPath, 'utf8')); - if (cfg.appId) return String(cfg.appId); - } catch (e) { - logToFile(state, `app-config.json parse failed: ${errMsg(e)}`); - } - return null; -} - -async function stepInitWizard(state: State): Promise { - const tmp = mkdtempSync(join(tmpdir(), 'brevo-smoke-init-')); - state.initTmpDir = tmp; - - // Wizard prompts (must stay in sync with `brevo app init` flow): - // 1. App name → unique, readable, traceable name - // 2. Distribution type → '' = accept default (Private) - // 3. OAuth callback → '' = accept default - // 4. Add another? → n - // 5. Generate starter? → n (scaffold has its own step) - const stamp = state.opts.ci - ? `${process.env.GITHUB_RUN_ID || Date.now()}-${process.env.GITHUB_RUN_ATTEMPT || '1'}` - : String(Date.now()); - const expectedName = `brevo-cli-smoke-init-${stamp}`; - const answers = [expectedName, '', '', 'n', 'n']; - - // Paced writes: spawnSync(input:) closes stdin immediately on EOF and - // inquirer reads ahead of its prompts before then, defaulting prompts that - // had no answer yet. Use execScriptedStdin which writes lines one at a time - // with a short delay so inquirer reads each answer as its prompt renders. - const r = await execScriptedStdin('brevo', ['app', 'init'], state, { - cwd: tmp, - answers, - interLineDelayMs: 400, - }); - if (r.exitCode !== 0) throw new Error(`brevo app init exited ${r.exitCode}`); - const output = r.stdout; - - // Primary: parse "App ID: " from wizard output. UUID format only — the - // wizard prints other ids (Client ID is 32 hex) which we explicitly don't match. - const uuidPattern = /App ID:\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i; - let appId: string | null = uuidPattern.exec(output)?.[1] ?? null; - - if (!appId) appId = await findInitAppByName(state, expectedName); - if (!appId) appId = readInitAppIdFromConfig(state, tmp); - - // NO list-diff fallback. A blind "delete the first new app" path could - // remove an app a human (or another process) just created on the same - // account. If we can't identify our app by parsing wizard output, by our - // exact unique name, or via app-config.json, we refuse to guess — the - // orphan warning prints the suggested cleanup commands and the step fails. - if (!appId) { - printOrphanWarning(state, [], expectedName); - throw new Error( - `could not identify init-created app (expected name "${expectedName}"); refusing to guess. See orphan warning above for manual cleanup.`, - ); - } - - state.initAppId = appId; - return `init created app ${appId} in ${tmp}`; -} - -function findAppByName(listJson: unknown, name: string): string | null { - const items = Array.isArray(listJson) - ? listJson - : ((listJson as { apps?: unknown[]; data?: unknown[] })?.apps ?? - (listJson as { data?: unknown[] })?.data ?? - []); - for (const item of items as Array>) { - if (item.name === name) { - const id = item.app_id ?? item.appId ?? item.id; - if (typeof id === 'string' || typeof id === 'number') return String(id); - } - } - return null; -} - -function printOrphanWarning(state: State, suspectIds: string[], expectedName?: string): void { - process.stdout.write(`\n${COLOR.yellow}${COLOR.bold}⚠ ORPHAN APP WARNING${COLOR.reset}\n`); - process.stdout.write( - `${COLOR.yellow}The init wizard likely created an app but the script could not identify it.${COLOR.reset}\n`, - ); - if (expectedName) { - process.stdout.write( - `${COLOR.yellow}Expected app name: ${COLOR.bold}${expectedName}${COLOR.reset}${COLOR.yellow} (not found in list)${COLOR.reset}\n`, - ); - } - if (suspectIds.length > 0) { - process.stdout.write( - `${COLOR.yellow}Suspect app ids: ${suspectIds.join(', ')}${COLOR.reset}\n`, - ); - } - try { - const r = execOrThrow('brevo', ['app', 'list', '--json'], state); - const apps = parseJson(r.stdout); - const items = Array.isArray(apps) ? apps : ((apps as { apps?: unknown[] }).apps ?? []); - process.stdout.write(`${COLOR.yellow}Apps currently on the account:${COLOR.reset}\n`); - for (const a of items as Array>) { - const rawId = a.app_id ?? a.appId ?? a.id; - const id = typeof rawId === 'string' || typeof rawId === 'number' ? String(rawId) : '?'; - const name = typeof a.name === 'string' ? a.name : '?'; - const flag = name.startsWith('brevo-cli-smoke') - ? ` ${COLOR.red}← likely smoke leak${COLOR.reset}` - : ''; - process.stdout.write(` - ${id} ${name}${flag}\n`); - } - process.stdout.write( - `${COLOR.yellow}Delete any that look like smoke artifacts with:${COLOR.reset}\n` + - ` ${COLOR.dim}brevo app delete --app-id --force${COLOR.reset}\n`, - ); - } catch (e) { - logToFile(state, `orphan listing failed: ${errMsg(e)}`); - } -} - -function stepDeleteInitApp(state: State): string { - if (!state.initAppId) throw new Error('no initAppId to delete'); - const id = state.initAppId; - execOrThrow('brevo', ['app', 'delete', '--app-id', id, '--force', '--json'], state); - state.initAppId = null; - return `app ${id} deleted`; -} - -function stepLogout(state: State): string { - execOrThrow('brevo', ['logout', '--force', '--json'], state); - // whoami may exit non-zero when unauthenticated; accept either as "logged out" - const r = exec('brevo', ['whoami', '--json'], state); - if (r.exitCode === 0) { - try { - const obj = parseJson>(r.stdout); - if (obj.authenticated || obj.user || obj.email) { - throw new Error('still authenticated after logout'); - } - } catch { - // unparseable whoami output post-logout is acceptable - } - } - return 'logged out'; -} - -function killStartChild(state: State): void { - if (!state.startChild) return; - try { - // `.killed` only means a signal was already sent, not that the process - // exited — always send SIGKILL (a no-op on a dead pid) and drop the ref. - state.startChild.kill('SIGKILL'); - } catch { - // ignore - } - state.startChild = null; -} - -function removeTmpDirs(state: State, logFailures: boolean): void { - for (const dir of [state.mainTmpDir, state.initTmpDir]) { - if (dir && existsSync(dir)) { - try { - rmSync(dir, { recursive: true, force: true }); - } catch (e) { - if (logFailures) { - logToFile(state, `rm ${dir} failed: ${errMsg(e)}`); - } - } - } - } - state.mainTmpDir = null; - state.initTmpDir = null; -} - -function stepFinalCleanup(state: State): string { - if (state.linked) { - if (state.opts.against === 'local') exec('yarn', ['unlink'], state); - else exec('npm', ['uninstall', '-g', '@getbrevo/cli'], state); - state.linked = false; - } - removeTmpDirs(state, true); - killStartChild(state); - return 'cleanup done'; -} - -// ──────────────────────────── trap cleanup ──────────────────────────── - -function trapDeleteApps(state: State): void { - for (const appId of [state.mainAppId, state.initAppId]) { - if (!appId) continue; - try { - spawnSync('brevo', ['app', 'delete', '--app-id', appId, '--force', '--json'], { - timeout: 30_000, - }); - logToFile(state, `trap: deleted app ${appId}`); - } catch (e) { - logToFile(state, `trap: failed to delete app ${appId}: ${errMsg(e)}`); - } - } - state.mainAppId = null; - state.initAppId = null; -} - -function trapUninstallCli(state: State): void { - if (!state.linked) return; - try { - if (state.opts.against === 'local') spawnSync('yarn', ['unlink'], { timeout: 30_000 }); - else spawnSync('npm', ['uninstall', '-g', '@getbrevo/cli'], { timeout: 30_000 }); - } catch { - // ignore - } - state.linked = false; -} - -// Best-effort: synchronous-ish, no throws. Runs on SIGINT/SIGTERM/uncaughtException -// and as a final safety net after the run loop. Designed to be idempotent. -function bestEffortCleanup(state: State): void { - killStartChild(state); - trapDeleteApps(state); - removeTmpDirs(state, false); - trapUninstallCli(state); -} - -// ──────────────────────────── report ──────────────────────────── - function writeReport(state: State, ok: boolean): void { if (!state.opts.reportPath) return; const report = { @@ -1064,9 +223,17 @@ function writeReport(state: State, ok: boolean): void { against: state.opts.against, ci: state.opts.ci, logFile: state.logFile, + capabilities: state.caps, + suites: state.opts.suites, + publicFlow: state.opts.suites.includes('public') ? state.publicObs : 'suite not selected', + rateLimitWaits: state.rateLimitWaits, + // Anything here is a real leak on the account, not a test detail. + orphanedAppIds: state.orphanedAppIds, steps: state.stepResults, }; - writeFileSync(state.opts.reportPath, JSON.stringify(report, null, 2)); + // Step details/errors quote CLI output, and the report is a CI artefact — run + // it through the same redaction the log file gets. + writeFileSync(state.opts.reportPath, redact(JSON.stringify(report, null, 2))); } // ──────────────────────────── main ──────────────────────────── @@ -1112,25 +279,21 @@ async function resolvePort(opts: Options): Promise { } } +// Setup and teardown always run; the middle is whichever suites were selected. +// Teardown order matters: leftover-app cleanup must precede Logout / Final +// cleanup, which destroy the credentials and the linked binary it needs. function buildSteps(opts: Options): Array<[string, StepFn]> { + const selected = opts.suites.flatMap((name) => { + const suite = SUITES[name]; + if (!suite) throw new Error(`unknown suite: ${name}`); + return suite.steps; + }); return [ ['Pre-flight', stepPreflight], ['Reinstall local', stepReinstall], ['Auth lifecycle', stepAuth], - ['App create', stepAppCreate], - ['App credentials', stepAppCredentials], - ['App update', stepAppUpdate], - ['Verify rename', stepVerifyRename], - ['Negative: public app rejected', stepPublicAppRejected], - ['Scaffold', stepScaffold], - ['Start briefly', stepStartBriefly], - ['Delete main test app', stepDeleteMainApp], - ...(opts.withInit - ? ([ - ['brevo app init wizard', stepInitWizard], - ['Delete init-created app', stepDeleteInitApp], - ] as Array<[string, StepFn]>) - : []), + ...selected, + ['Cleanup: leftover apps', stepDeleteLeftoverApps], ['Logout', stepLogout], ['Final cleanup', stepFinalCleanup], ]; @@ -1157,10 +320,10 @@ async function runSteps( function hasLeftoverState(state: State): boolean { return Boolean( - state.mainAppId || + state.mainApp || + state.publicApp || state.initAppId || - state.mainTmpDir || - state.initTmpDir || + state.tmpDirs.length > 0 || state.linked || state.startChild, ); @@ -1171,7 +334,7 @@ async function main(): Promise { try { opts = parseArgs(process.argv.slice(2)); } catch (e) { - process.stderr.write(`${e instanceof Error ? e.message : String(e)}\n\n`); + process.stderr.write(`${errMsg(e)}\n\n`); printHelp(); process.exit(2); } @@ -1182,14 +345,19 @@ async function main(): Promise { opts, logFile, logFd, - mainAppId: null, + workRoot: null, + tmpDirs: [], + mainApp: null, + publicApp: null, initAppId: null, - mainTmpDir: null, - mainScaffoldDir: null, - initTmpDir: null, linked: false, + caps: null, startChild: null, stepResults: [], + publicObs: {}, + rateLimitWaits: 0, + orphanedAppIds: [], + brevoBin: null, }; installCleanupTraps(state); @@ -1216,8 +384,9 @@ function printColouredSummary(state: State, allOk: boolean, firstFailed: number) const width = 60; const rule = '═'.repeat(width); const thin = '─'.repeat(width); - const passed = state.stepResults.filter((s) => s.ok).length; - const failed = state.stepResults.length - passed; + const skipped = state.stepResults.filter((s) => s.skipped).length; + const passed = state.stepResults.filter((s) => s.ok && !s.skipped).length; + const failed = state.stepResults.filter((s) => !s.ok).length; const headerColor = allOk ? COLOR.green : COLOR.red; const title = allOk ? ' SMOKE TEST PASSED' : ' SMOKE TEST FAILED'; @@ -1227,20 +396,36 @@ function printColouredSummary(state: State, allOk: boolean, firstFailed: number) state.stepResults.forEach((r, i) => { const n = String(i + 1).padStart(2, ' '); - const name = r.name.padEnd(28, ' '); - const status = r.ok ? `${COLOR.green}✓ PASS${COLOR.reset}` : `${COLOR.red}✗ FAIL${COLOR.reset}`; + const name = r.name.padEnd(32, ' '); + let status: string; + if (r.skipped) status = `${COLOR.yellow}⊘ SKIP${COLOR.reset}`; + else if (r.ok) status = `${COLOR.green}✓ PASS${COLOR.reset}`; + else status = `${COLOR.red}✗ FAIL${COLOR.reset}`; const ms = `${COLOR.dim}(${formatMs(r.durationMs)})${COLOR.reset}`; - const detail = r.ok ? '' : ` ${COLOR.red}— ${r.error?.slice(0, 80) ?? ''}${COLOR.reset}`; + let detail = ''; + if (!r.ok) detail = ` ${COLOR.red}— ${r.error?.slice(0, 80) ?? ''}${COLOR.reset}`; + else if (r.skipped) detail = ` ${COLOR.yellow}— ${r.detail?.slice(0, 80) ?? ''}${COLOR.reset}`; process.stdout.write(` ${n}. ${name} ${status} ${ms}${detail}\n`); }); process.stdout.write(`${COLOR.dim}${thin}${COLOR.reset}\n`); const failedPart = failed > 0 ? `, ${COLOR.red}${failed} failed${COLOR.reset}` : ''; + const skippedPart = skipped > 0 ? `, ${COLOR.yellow}${skipped} skipped${COLOR.reset}` : ''; const firstFailedPart = allOk ? '' : ` ${COLOR.dim}(first failure: step ${firstFailed})${COLOR.reset}`; - const counts = ` ${COLOR.green}${passed} passed${COLOR.reset}${failedPart}${firstFailedPart}`; + const counts = ` ${COLOR.green}${passed} passed${COLOR.reset}${failedPart}${skippedPart}${firstFailedPart}`; process.stdout.write(`${counts}\n`); + if (state.rateLimitWaits > 0) { + process.stdout.write( + ` ${COLOR.yellow}${state.rateLimitWaits} rate-limit wait(s) — the API throttled this run${COLOR.reset}\n`, + ); + } + if (state.orphanedAppIds.length > 0) { + process.stdout.write( + ` ${COLOR.red}LEAKED ${state.orphanedAppIds.length} app(s): ${state.orphanedAppIds.join(', ')}${COLOR.reset}\n`, + ); + } process.stdout.write(` ${COLOR.dim}Log: ${state.logFile}${COLOR.reset}\n`); if (state.opts.reportPath) { process.stdout.write(` ${COLOR.dim}Report: ${state.opts.reportPath}${COLOR.reset}\n`); diff --git a/scripts/smoke/core.ts b/scripts/smoke/core.ts new file mode 100644 index 0000000..18143f4 --- /dev/null +++ b/scripts/smoke/core.ts @@ -0,0 +1,1195 @@ +/* + * Shared infrastructure for the smoke suites: options/state types, logging, + * subprocess plumbing (incl. rate-limit retry), assertions, capability + * detection, app create/upload/delete helpers, and teardown. + * + * Flow-specific steps live in ./private-app.ts and ./public-app.ts so either + * lifecycle can be run on its own. + */ + +import { + appendFileSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { basename, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { ChildProcess, spawn, spawnSync } from 'node:child_process'; +import * as http from 'node:http'; +import * as net from 'node:net'; + +export interface Options { + skipAuth: boolean; + verbose: boolean; + port: number; + portExplicit: boolean; + reportPath: string | null; + ci: boolean; + against: 'local' | 'published'; + // Which suite modules to run, in order. See SUITES in the runner. + suites: string[]; +} + +export interface StepResult { + name: string; + ok: boolean; + skipped?: boolean; + durationMs: number; + error?: string; + detail?: string; +} + +// An app this run created, and the project directory `brevo app create` wrote +// for it. Both are needed for cleanup; the directory is also where `upload`, +// `scaffold`, `start` and `submit` must run from (they read app-config.json +// from the cwd). +export interface SmokeApp { + appId: string; + name: string; + distribution: 'private' | 'public'; + projectDir: string; + redirectUri: string; +} + +// What the public lifecycle actually observed. Recorded rather than asserted +// where the backend — not the CLI — owns the value (review state, form link), +// so a run's report explains what happened without the script pretending to +// control it. +export interface PublicObservations { + stateBeforeSubmit?: string; + formUrl?: string; + stateAfterSubmit?: string; + withdrawn?: boolean; + withdrawReason?: string; + stateAfterWithdraw?: string; +} + +export interface State { + opts: Options; + logFile: string; + logFd: number; + // Root tmp dir that every `brevo app create` runs from, so the `./` + // directory it creates lands somewhere disposable. + workRoot: string | null; + // Every tmp dir the run created, removed by removeTmpDirs. + tmpDirs: string[]; + mainApp: SmokeApp | null; + publicApp: SmokeApp | null; + initAppId: string | null; + linked: boolean; + caps: Record | null; + startChild: ChildProcess | null; + stepResults: StepResult[]; + publicObs: PublicObservations; + // How many times a call was retried after a rate limit — a slow run is then + // explicable from the report rather than looking like a hang. + rateLimitWaits: number; + // Apps the cleanup could not delete. Non-empty means a real leak. + orphanedAppIds: string[]; + // Absolute path to the CLI under test, resolved once in stepReinstall. + brevoBin: string | null; +} + +// ──────────────────────────── logging ──────────────────────────── + +// Strip values that look like Brevo secrets before any line hits the log file, +// since this log is what gets uploaded as a CI artefact. +export function redact(s: string): string { + return ( + s + .replaceAll(/xkeysib-[A-Za-z0-9_-]+/g, 'xkeysib-***REDACTED***') + .replaceAll(/xsmtpsib-[A-Za-z0-9_-]+/g, 'xsmtpsib-***REDACTED***') + .replaceAll(/"clientSecret"\s*:\s*"[^"]+"/g, '"clientSecret":"***REDACTED***"') + .replaceAll(/"client_secret"\s*:\s*"[^"]+"/g, '"client_secret":"***REDACTED***"') + // .env / .env.local lines the scaffold writes, in case a step ever cats them. + .replaceAll(/\b(CLIENT_SECRET|BREVO_API_KEY)=\S+/g, '$1=***REDACTED***') + ); +} + +export function logToFile(state: State, line: string): void { + appendFileSync(state.logFd, `${new Date().toISOString()} ${redact(line)}\n`); +} + +export function announce(state: State, n: number, title: string): void { + const line = `\n▶ Step ${n}: ${title}`; + process.stdout.write(line + '\n'); + logToFile(state, line); +} + +export type StepOutcome = 'ok' | 'failed' | 'skipped'; + +export const OUTCOME_DISPLAY: Record = { + ok: { icon: '✓', word: 'ok' }, + skipped: { icon: '⊘', word: 'skipped' }, + failed: { icon: '✗', word: 'FAILED' }, +}; + +export function stepDone(state: State, outcome: StepOutcome, detail: string, ms: number) { + const { icon, word } = OUTCOME_DISPLAY[outcome]; + const line = ` ${icon} ${detail} — ${word} (${formatMs(ms)})`; + process.stdout.write(line + '\n'); + logToFile(state, line); +} + +export function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +export function formatMs(ms: number): string { + return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`; +} + +// ──────────────────────────── colour ──────────────────────────── + +// Honour NO_COLOR and non-TTY stdout so CI logs stay clean. +export const COLOR_ENABLED = !process.env.NO_COLOR && Boolean(process.stdout.isTTY); + +export const COLOR = { + reset: COLOR_ENABLED ? '\x1b[0m' : '', + bold: COLOR_ENABLED ? '\x1b[1m' : '', + dim: COLOR_ENABLED ? '\x1b[2m' : '', + red: COLOR_ENABLED ? '\x1b[31m' : '', + green: COLOR_ENABLED ? '\x1b[32m' : '', + yellow: COLOR_ENABLED ? '\x1b[33m' : '', + cyan: COLOR_ENABLED ? '\x1b[36m' : '', +}; + +// ──────────────────────────── subprocess helpers ──────────────────────────── + +export interface ExecOptions { + cwd?: string; + input?: string; + inherit?: boolean; + // Hard cap, used by the trap paths so cleanup can't hang on a signal. + timeoutMs?: number; +} + +export interface ExecResult { + stdout: string; + stderr: string; + exitCode: number; +} + +// The API rate-limits a busy account (a full run makes ~40 calls, and the CLI's +// own retry gives up after one attempt). A 429 is an environment condition, not +// a CLI defect: without this, every step after the limiter kicks in fails, and +// the negative probes fail for the *wrong* reason — asserting a mapped message +// against "Rate limited. Retrying in 5 seconds...". Retried centrally so no step +// has to think about it, and only when the failure actually looks like a limit. +export const RATE_LIMIT_RE = /rate limit|429|too many requests/i; + +export const RATE_LIMIT_BACKOFF_MS = [5000, 15_000, 30_000]; + +// exec() is spawnSync-based and several steps are plain sync functions, so an +// async sleep can't be awaited here. +export function sleepSync(ms: number): void { + spawnSync(process.execPath, [ + '-e', + `Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${ms})`, + ]); +} + +// Every `brevo` call goes through the absolute path resolved once in +// stepReinstall rather than the bare name. Searching PATH per call is a security +// smell (Sonar S4036 — a writable PATH entry could shadow the binary), but the +// practical reason is sharper: an unrelated `brevo` earlier on PATH silently +// makes the entire run exercise the wrong build. A live run came close to +// passing against a stale `@dtsl/brevo-cli` install for exactly that reason. +// +// The bare name is only a fallback for the trap paths, which can fire before +// stepReinstall has resolved anything. +export const BREVO_CMD_FALLBACK = 'brevo'; + +// Toolchain commands. Named once so no call site embeds a bare command literal. +export const PKG_YARN = 'yarn'; +export const PKG_NPM = 'npm'; +export const PACKAGE_NAME = '@getbrevo/cli'; +export const CMD_WHICH = 'which'; + +export function brevoCmd(state: State): string { + return state.brevoBin ?? BREVO_CMD_FALLBACK; +} + +export function execOnce(cmd: string, args: string[], state: State, opts: ExecOptions): ExecResult { + const result = spawnSync(cmd, args, { + cwd: opts.cwd, + input: opts.input, + encoding: 'utf8', + env: process.env, + ...(opts.timeoutMs ? { timeout: opts.timeoutMs } : {}), + stdio: opts.inherit ? 'inherit' : ['pipe', 'pipe', 'pipe'], + }); + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + exitCode: result.status ?? -1, + }; +} + +// `inherit` leaves no captured output to classify, so such a call is never +// treated as rate-limited. +export function looksRateLimited(r: ExecResult, opts: ExecOptions): boolean { + if (r.exitCode === 0 || opts.inherit) return false; + return RATE_LIMIT_RE.test(r.stderr + r.stdout); +} + +export function execWithRateLimitRetry( + cmd: string, + args: string[], + state: State, + opts: ExecOptions, +): ExecResult { + let r = execOnce(cmd, args, state, opts); + for (let attempt = 0; attempt < RATE_LIMIT_BACKOFF_MS.length; attempt++) { + if (!looksRateLimited(r, opts)) break; + const wait = RATE_LIMIT_BACKOFF_MS[attempt] ?? 30_000; + const of = RATE_LIMIT_BACKOFF_MS.length + 1; + state.rateLimitWaits++; + const note = `rate limited — waiting ${formatMs(wait)} and retrying (attempt ${attempt + 2}/${of})`; + logToFile(state, note); + process.stdout.write(` ${COLOR.yellow}⏳ ${note}${COLOR.reset}\n`); + sleepSync(wait); + r = execOnce(cmd, args, state, opts); + } + return r; +} + +export function exec( + cmd: string, + args: string[], + state: State, + opts: ExecOptions = {}, +): ExecResult { + const pretty = `$ ${cmd} ${args.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')}`; + logToFile(state, pretty); + if (state.opts.verbose) process.stdout.write(` ${pretty}\n`); + + const r = execWithRateLimitRetry(cmd, args, state, opts); + + if (!opts.inherit) { + if (r.stdout) logToFile(state, r.stdout.trimEnd()); + if (r.stderr) logToFile(state, '[stderr] ' + r.stderr.trimEnd()); + if (state.opts.verbose) { + if (r.stdout) process.stdout.write(r.stdout); + if (r.stderr) process.stderr.write(r.stderr); + } + } + return r; +} + +export function execOrThrow( + cmd: string, + args: string[], + state: State, + opts: ExecOptions = {}, +): ExecResult { + const r = exec(cmd, args, state, opts); + if (r.exitCode !== 0) { + throw new Error( + `${cmd} ${args.join(' ')} exited ${r.exitCode}: ${(r.stderr || r.stdout).trim().split('\n')[0]}`, + ); + } + return r; +} + +// Drive a child process by writing scripted answers to its stdin with a small +// delay between lines. `spawnSync` with `input:` closes stdin as soon as the +// buffer is written, which trips up readline-based prompt libraries (inquirer +// in particular) — they see EOF before the first prompt is rendered and fall +// back to defaults. Paced writes give the prompt loop time to read each line. +export async function execScriptedStdin( + cmd: string, + args: string[], + state: State, + opts: { cwd?: string; answers: string[]; interLineDelayMs?: number }, +): Promise<{ stdout: string; exitCode: number }> { + const pretty = `$ ${cmd} ${args.join(' ')} (scripted stdin)`; + logToFile(state, pretty); + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + cwd: opts.cwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: process.env, + }); + let buf = ''; + const onData = (d: Buffer) => { + const s = d.toString(); + buf += s; + logToFile(state, s.trimEnd()); + }; + child.stdout?.on('data', onData); + child.stderr?.on('data', onData); + child.on('error', reject); + child.on('exit', (code) => resolve({ stdout: buf, exitCode: code ?? -1 })); + const delay = opts.interLineDelayMs ?? 250; + (async () => { + for (const line of opts.answers) { + await sleep(delay); + if (!child.stdin || child.stdin.destroyed) break; + child.stdin.write(line + '\n'); + } + await sleep(delay); + child.stdin?.end(); + })().catch((e) => logToFile(state, `stdin writer error: ${e}`)); + }); +} + +// Run a child process while letting the user see (and respond to) its output +// in real time. stdin is inherited so the user can answer interactive prompts; +// stdout/stderr are tee'd to terminal AND captured into a buffer for parsing +// (e.g. extracting "App ID: " from the init wizard). +export function execStreaming( + cmd: string, + args: string[], + state: State, + opts: { cwd?: string } = {}, +): Promise<{ stdout: string; exitCode: number }> { + const pretty = `$ ${cmd} ${args.join(' ')}`; + logToFile(state, pretty); + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + cwd: opts.cwd, + stdio: ['inherit', 'pipe', 'pipe'], + env: process.env, + }); + let buf = ''; + const onData = (d: Buffer) => { + const s = d.toString(); + buf += s; + process.stdout.write(s); + logToFile(state, s.trimEnd()); + }; + child.stdout?.on('data', onData); + child.stderr?.on('data', onData); + child.on('error', reject); + child.on('exit', (code) => resolve({ stdout: buf, exitCode: code ?? -1 })); + }); +} + +// The app list endpoint is eventually consistent with create/delete +// (documented in src/commands/app/list.ts). Poll a few times before deciding +// an app is missing or still present. +export async function findAppInList( + state: State, + expectedId: string, + shouldBePresent: boolean, + attempts = 4, +): Promise { + const backoff = [500, 1000, 2000, 4000]; + for (let i = 0; i < attempts; i++) { + const r = execOrThrow(brevoCmd(state), ['app', 'list', '--json'], state); + const ids = collectAppIds(parseJson(r.stdout)); + if (ids.has(expectedId) === shouldBePresent) return true; + if (i < attempts - 1) await sleep(backoff[i] ?? 4000); + } + return false; +} + +export function parseJson(stdout: string): T { + // brevo sometimes prints a spinner/banner before --json output, so scan to the first { or [. + const idx = stdout.search(/[{[]/); + if (idx < 0) throw new Error(`no JSON in output: ${stdout.slice(0, 200)}`); + return JSON.parse(stdout.slice(idx)); +} + +export function readJsonFile>(path: string): T { + return JSON.parse(readFileSync(path, 'utf8')) as T; +} + +// `brevo app list --json` returns `app_id` (snake_case, per src/types.ts). +// `brevo app create --json` returns `appId` (camelCase). Some endpoints use +// plain `id`. We accept all three so comparisons work across boundaries. +export function pickId(obj: Record): string { + const raw = obj.app_id ?? obj.appId ?? obj.id; + return typeof raw === 'string' || typeof raw === 'number' ? String(raw) : ''; +} + +export function listItems(listJson: unknown): Array> { + const items = Array.isArray(listJson) + ? listJson + : ((listJson as { apps?: unknown[]; data?: unknown[] })?.apps ?? + (listJson as { data?: unknown[] })?.data ?? + []); + return items as Array>; +} + +export function collectAppIds(listJson: unknown): Set { + const ids = new Set(); + for (const item of listItems(listJson)) { + const id = pickId(item); + if (id) ids.add(id); + } + return ids; +} + +export function findAppByName(listJson: unknown, name: string): string | null { + for (const item of listItems(listJson)) { + if (item.name === name) { + const id = pickId(item); + if (id) return id; + } + } + return null; +} + +// ──────────────────────────── assertions ──────────────────────────── + +export function must(condition: unknown, message: string): void { + if (!condition) throw new Error(message); +} + +export function asStringArray(value: unknown, what: string): string[] { + if (!Array.isArray(value)) + throw new TypeError(`${what} is not an array: ${JSON.stringify(value)}`); + return value.map(String); +} + +export function sameSet(a: string[], b: string[]): boolean { + return JSON.stringify([...a].sort()) === JSON.stringify([...b].sort()); +} + +export function firstLine(text: string): string { + const line = text + .split('\n') + .map((l) => l.trim()) + .find((l) => l.length > 0); + return (line ?? '(no output)').slice(0, 200); +} + +// A raw stack frame in user-facing output means the error escaped the CliError +// / ApiError mapping in src/lib/errors.ts. Checked line by line with two +// anchored patterns rather than one multi-line regex: `\n\s+at .+:\d+:\d+` +// backtracks super-linearly (Sonar S8786), since `\s` also matches the newline +// and `.+` competes with the `:line:col` tail for the same characters. +export const STACK_FRAME_HEAD_RE = /^[ \t]+at \S/; + +export const STACK_FRAME_TAIL_RE = /:\d+:\d+\)?$/; + +export function hasStackFrame(text: string): boolean { + return text + .split('\n') + .map((line) => line.trimEnd()) + .some((line) => STACK_FRAME_HEAD_RE.test(line) && STACK_FRAME_TAIL_RE.test(line)); +} + +export interface FailureExpectation { + // Human label for the probe, used in the step detail and failure message. + what: string; + // At least one must match the command's output. + patterns: RegExp[]; + // Exit codes from src/lib/exit-codes.ts that are acceptable here. + exitCodes: number[]; +} + +// A negative probe must fail the way the CLI documents it: a mapped, +// user-facing message from src/lang/en.ts and a known exit code. Failing with +// an unmapped error (raw stack trace, or an exit code we don't recognise) is a +// regression even though the command did "fail as expected", so assert both. +export function assertMappedFailure(r: ExecResult, exp: FailureExpectation): string { + const text = `${r.stderr}\n${r.stdout}`; + must(r.exitCode !== 0, `${exp.what}: expected a non-zero exit, got 0 — ${firstLine(text)}`); + must( + exp.exitCodes.includes(r.exitCode), + `${exp.what}: exit ${r.exitCode} not in expected ${exp.exitCodes.join('|')} — ${firstLine(text)}`, + ); + must(!hasStackFrame(text), `${exp.what}: output contains a raw stack trace`); + must( + exp.patterns.some((p) => p.test(text)), + `${exp.what}: exit ${r.exitCode} but the message is not the mapped one — ${firstLine(text)}`, + ); + return `${exp.what} → exit ${r.exitCode}`; +} + +// ──────────────────────────── capability detection ──────────────────────────── + +// Commands whose presence the public-app steps depend on. Each landed in its +// own ticket (BEX-250/251/252/253), and `--against=published` runs whatever +// npm currently serves — so detect instead of assuming. +export const GATED_COMMANDS = ['upload', 'submit', 'status', 'withdraw'] as const; + +export type GatedCommand = (typeof GATED_COMMANDS)[number]; + +export function listedInHelp(helpText: string, command: string): boolean { + return new RegExp(String.raw`brevo app ${command}\b`).test(helpText); +} + +// Detection is help-text only, on purpose. `brevo app --help` exits 0 +// (commander falls back to printing the root help), so probing a subcommand +// can't tell present from absent — and running it for real isn't an option +// since these commands mutate or prompt. +export function detectCapabilities(state: State): Record { + const help = exec(brevoCmd(state), ['--help'], state); + const helpText = help.stdout + help.stderr; + const caps: Record = {}; + + // `brevo app create` exists in every version that has ever shipped, so it + // doubles as a canary for "we can read this help layout". If even that is + // missing, the layout changed under us — gate nothing rather than silently + // skipping the whole public lifecycle. + if (!listedInHelp(helpText, 'create')) { + logToFile( + state, + 'capability detection: unrecognised --help layout; assuming all commands present', + ); + for (const name of GATED_COMMANDS) caps[name] = true; + state.caps = caps; + return caps; + } + + for (const name of GATED_COMMANDS) { + caps[name] = listedInHelp(helpText, name); + } + logToFile(state, `capabilities: ${JSON.stringify(caps)}`); + state.caps = caps; + return caps; +} + +export class SkippedStep extends Error {} + +export function skip(reason: string): never { + throw new SkippedStep(reason); +} + +export function requireCommand(state: State, name: GatedCommand): void { + if (state.caps?.[name] === false) { + skip(`brevo app ${name} not available in this build (--against=${state.opts.against})`); + } +} + +// ──────────────────────────── port helpers ──────────────────────────── + +export function isPortFree(port: number): Promise { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.once('error', () => resolve(false)); + srv.once('listening', () => srv.close(() => resolve(true))); + srv.listen(port); + }); +} + +export async function assertPortFree(port: number): Promise { + if (!(await isPortFree(port))) throw new Error(`port ${port} already in use`); +} + +export async function pickFreePort(start: number, range = 50): Promise { + for (let port = start; port < start + range; port++) { + if (await isPortFree(port)) return port; + } + throw new Error(`no free port found in [${start}, ${start + range})`); +} + +export function probeHttp(port: number): Promise { + return new Promise((resolve) => { + const req = http.get({ host: '127.0.0.1', port, path: '/', timeout: 1000 }, (res) => { + res.resume(); + resolve(true); + }); + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + }); +} + +export function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +export function waitForExit(child: ChildProcess, timeoutMs: number): Promise { + return new Promise((resolve) => { + if (child.exitCode !== null) { + resolve(); + return; + } + const t = setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch { + // ignore + } + resolve(); + }, timeoutMs); + child.once('exit', () => { + clearTimeout(t); + resolve(); + }); + }); +} + +// ──────────────────────────── steps ──────────────────────────── + +export type StepFn = (state: State) => Promise | string; + +export function stepPreflight(state: State): string { + const node = execOrThrow('node', ['-v'], state).stdout.trim(); + const yarn = execOrThrow(PKG_YARN, ['-v'], state).stdout.trim(); + return `node ${node}, yarn ${yarn}, against=${state.opts.against}, ci=${state.opts.ci}`; +} + +export function stepReinstall(state: State): string { + // Tolerate errors here — prior installations may not exist. + exec(PKG_YARN, ['unlink'], state); + exec(PKG_NPM, ['uninstall', '-g', PACKAGE_NAME], state); + + if (state.opts.against === 'local') { + execOrThrow(PKG_YARN, ['build'], state); + execOrThrow(PKG_YARN, ['link'], state); + } else { + execOrThrow(PKG_NPM, ['install', '-g', `${PACKAGE_NAME}@latest`], state); + } + state.linked = true; + + // Resolve the binary once, then invoke it by absolute path for the rest of the + // run (see brevoCmd). + const which = execOrThrow(CMD_WHICH, [BREVO_CMD_FALLBACK], state).stdout.trim(); + if (!which) throw new Error('could not resolve the `brevo` binary after install'); + state.brevoBin = which; + const version = execOrThrow(brevoCmd(state), ['--version'], state).stdout.trim(); + + const caps = detectCapabilities(state); + const missing = GATED_COMMANDS.filter((c) => !caps[c]); + const capNote = missing.length > 0 ? `, missing: ${missing.join(', ')}` : ''; + return `brevo ${version} at ${which}${capNote}`; +} + +export async function stepAuth(state: State): Promise { + if (state.opts.skipAuth) { + const r = execOrThrow(brevoCmd(state), ['whoami', '--json'], state); + parseJson(r.stdout); + return 'already authenticated (--skip-auth)'; + } + + exec(brevoCmd(state), ['logout', '--force', '--json'], state); + + if (state.opts.ci) { + // brevo login picks up BREVO_API_KEY from env automatically. + execOrThrow(brevoCmd(state), ['login', '--json'], state); + } else { + process.stdout.write(` ${COLOR.cyan}⏳ waiting for browser login...${COLOR.reset}\n`); + // --json short-circuits the post-login "Would you like to create an app?" + // prompt (see src/commands/login.ts) that would otherwise block the smoke + // run when the account has zero apps. The smoke test creates its own app + // in stepAppCreate, so that prompt is never useful here. + // + // Trade-off: --json also suppresses the browser-fallback URL. If your + // browser doesn't auto-open, the run will appear to hang. Run the login + // manually first (`brevo login`) then use `yarn smoke --skip-auth`. + const r = await execStreaming(brevoCmd(state), ['login', '--json'], state); + if (r.exitCode !== 0) throw new Error('brevo login failed'); + } + + const whoami = execOrThrow(brevoCmd(state), ['whoami', '--json'], state); + parseJson(whoami.stdout); + return 'logged in'; +} + +// Readable, traceable name. Concurrent CI runs are namespaced by GH run id. +// The `brevo-cli-smoke` prefix is what printOrphanWarning flags as a leak, so +// every app this script creates must keep it. +export function stampedName(state: State, label: string): string { + const stamp = state.opts.ci + ? `${process.env.GITHUB_RUN_ID || Date.now()}-${process.env.GITHUB_RUN_ATTEMPT || '1'}` + : String(Date.now()); + return `brevo-cli-smoke-${label}-${stamp}`; +} + +export function trackTmpDir(state: State, prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + state.tmpDirs.push(dir); + return dir; +} + +// `brevo app create` creates `./` under the cwd (BEX-255), so it must +// never run from the repo root. One disposable root is shared by both creates. +export function ensureWorkRoot(state: State): string { + const root = state.workRoot ?? trackTmpDir(state, 'brevo-smoke-work-'); + state.workRoot = root; + return root; +} + +// Render an optional string field for a step detail line without leaking +// "undefined" into the summary. +export function optStr(value: unknown): string { + return typeof value === 'string' && value ? value : '(none)'; +} + +// Mirrors computeSlug() in src/commands/app/scaffold.ts — the default project +// directory name is `./`, and asserting on it is how the +// smoke covers create's default-directory behaviour. +export function computeSlug(name: string): string { + return ( + name + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || 'my-app' + ); +} + +export const BASE_SCAFFOLD_FILES = [ + 'app-config.json', + '.gitignore', + 'AGENTS.md', + 'CLAUDE.md', + 'README.md', +]; + +export interface CreateSmokeAppOptions { + label: string; + distribution: 'private' | 'public'; + logoUri?: string; +} + +// Shared create path for both lifecycles: runs `brevo app create --json` from +// the disposable work root and asserts the whole create contract — response +// fields, the default `./` directory, the base project files, and the +// app-config.json written into it (BEX-255) — before returning the tracked app. +export async function createSmokeApp(state: State, opts: CreateSmokeAppOptions): Promise { + const workRoot = ensureWorkRoot(state); + const name = stampedName(state, opts.label); + const redirectUri = `http://localhost:${state.opts.port}/auth/callback`; + + const args = [ + 'app', + 'create', + '--name', + name, + '--distribution', + opts.distribution, + '--redirect-uri', + redirectUri, + ...(opts.logoUri ? ['--logo-uri', opts.logoUri] : []), + '--json', + ]; + const created = parseJson>( + execOrThrow(brevoCmd(state), args, state, { cwd: workRoot }).stdout, + ); + + const appId = pickId(created); + must(appId, `no app id in create output: ${JSON.stringify(created).slice(0, 200)}`); + // Register the app for cleanup before any further assertion can throw. + const app: SmokeApp = { + appId, + name, + distribution: opts.distribution, + projectDir: typeof created.directory === 'string' ? created.directory : '', + redirectUri, + }; + if (opts.distribution === 'public') state.publicApp = app; + else state.mainApp = app; + + must(created.appName === name, `create returned appName ${JSON.stringify(created.appName)}`); + must( + asStringArray(created.redirectUri, 'create redirectUri').includes(redirectUri), + `create response is missing redirect URI ${redirectUri}`, + ); + if (opts.logoUri) { + must( + created.logoUri === opts.logoUri, + `create returned logoUri ${JSON.stringify(created.logoUri)}`, + ); + } + + // Default directory: `./` relative to the cwd create ran in. A build + // from before BEX-255 doesn't create one — record that and let the steps that + // need a project directory skip themselves (see requireProjectDir), instead of + // failing a published-version run for a feature it doesn't have yet. + if (!app.projectDir) { + logToFile( + state, + `create --json reported no directory for app ${appId}; treating this build as pre-BEX-255`, + ); + must( + await findAppInList(state, appId, true), + `app ${appId} not present in list after create (after retries)`, + ); + return app; + } + must( + basename(app.projectDir) === computeSlug(name), + `create directory ${app.projectDir} does not match default slug ${computeSlug(name)}`, + ); + const missingFiles = BASE_SCAFFOLD_FILES.filter((f) => !existsSync(join(app.projectDir, f))); + must( + missingFiles.length === 0, + `create did not write ${missingFiles.join(', ')} into ${app.projectDir}`, + ); + + // app-config.json is the contract every later command reads. distribution_type + // and version are round-tripped from the server (see buildTemplateVars in + // scaffold.ts), so this also proves the create response persisted correctly. + const cfg = readJsonFile(join(app.projectDir, 'app-config.json')); + must( + String(cfg.appId) === appId, + `app-config.json appId ${JSON.stringify(cfg.appId)} != ${appId}`, + ); + must(cfg.appName === name, `app-config.json appName ${JSON.stringify(cfg.appName)} != ${name}`); + must( + cfg.distribution_type === opts.distribution, + `app-config.json distribution_type ${JSON.stringify(cfg.distribution_type)} != ${opts.distribution}`, + ); + must('version' in cfg, 'app-config.json has no version key'); + must(cfg.permittedUrls && cfg.support, 'app-config.json is missing permittedUrls/support blocks'); + if (opts.logoUri) { + must(cfg.logoUri === opts.logoUri, `app-config.json logoUri ${JSON.stringify(cfg.logoUri)}`); + } + const cfgUrls = asStringArray( + (cfg.auth as Record | undefined)?.redirectUrls, + 'app-config.json auth.redirectUrls', + ); + must(cfgUrls.includes(redirectUri), `app-config.json is missing redirect URL ${redirectUri}`); + + // List endpoint lags create — retry with backoff before declaring missing. + must( + await findAppInList(state, appId, true), + `app ${appId} not present in list after create (after retries)`, + ); + + return app; +} + +export function requireApp(app: SmokeApp | null, which: string): SmokeApp { + if (!app) throw new Error(`no ${which} app from the create step`); + return app; +} + +// upload / scaffold / start all read app-config.json from the cwd, so they need +// the project directory `brevo app create` writes (BEX-255). Older builds don't +// write one — skip rather than fail. +export function requireProjectDir(app: SmokeApp): string { + if (!app.projectDir) { + skip("the installed build's `app create` did not create a project directory (pre-BEX-255)"); + } + return app.projectDir; +} + +export const EXTRA_REDIRECT_URI = 'https://example.com/cb'; + +export function renamedName(app: SmokeApp): string { + return `${app.name}-renamed`; +} + +// `brevo app upload` (BEX-250, replaced `brevo app update`) pushes the local +// app-config.json. Edit the file the way a user would — rename + add a redirect +// URL — then assert the response diff and the config written back. +export function uploadApp(state: State, app: SmokeApp): Record { + const projectDir = requireProjectDir(app); + const configPath = join(projectDir, 'app-config.json'); + const cfg = readJsonFile(configPath); + const auth = (cfg.auth ?? {}) as Record; + const nextName = renamedName(app); + const nextUrls = [ + ...asStringArray(auth.redirectUrls, 'app-config.json auth.redirectUrls'), + EXTRA_REDIRECT_URI, + ]; + writeFileSync( + configPath, + JSON.stringify( + { ...cfg, appName: nextName, auth: { ...auth, redirectUrls: nextUrls } }, + null, + 2, + ), + ); + + const r = execOrThrow(brevoCmd(state), ['app', 'upload', '--yes', '--json'], state, { + cwd: projectDir, + }); + const res = parseJson>(r.stdout); + + must(String(res.appId) === app.appId, `upload returned appId ${JSON.stringify(res.appId)}`); + must( + res.name === nextName, + `upload returned name ${JSON.stringify(res.name)}, expected ${nextName}`, + ); + + const next = (res.next ?? {}) as Record; + const current = (res.current ?? {}) as Record; + must(next.name === nextName, `upload next.name ${JSON.stringify(next.name)} != ${nextName}`); + must( + current.name === app.name, + `upload current.name ${JSON.stringify(current.name)} != pre-rename ${app.name}`, + ); + const nextRemoteUrls = asStringArray(next.redirect_uris, 'upload next.redirect_uris'); + must( + nextRemoteUrls.includes(app.redirectUri), + `upload dropped the create-time redirect URI ${app.redirectUri}: ${nextRemoteUrls.join(', ')}`, + ); + must( + nextRemoteUrls.includes(EXTRA_REDIRECT_URI), + `upload is missing the added redirect URI ${EXTRA_REDIRECT_URI}: ${nextRemoteUrls.join(', ')}`, + ); + + // Success writes the server-confirmed values back into app-config.json. + const written = readJsonFile(configPath); + must(written.appName === nextName, `app-config.json was not rewritten with ${nextName}`); + must( + written.version === res.version, + `app-config.json version ${JSON.stringify(written.version)} != response ${JSON.stringify(res.version)}`, + ); + const writtenUrls = asStringArray( + (written.auth as Record | undefined)?.redirectUrls, + 'app-config.json auth.redirectUrls after upload', + ); + must( + writtenUrls.includes(app.redirectUri) && writtenUrls.includes(EXTRA_REDIRECT_URI), + `app-config.json redirect URLs after upload: ${writtenUrls.join(', ')}`, + ); + + return res; +} + +export async function deleteSmokeApp(state: State, app: SmokeApp): Promise { + execOrThrow( + brevoCmd(state), + ['app', 'delete', '--app-id', app.appId, '--force', '--json'], + state, + ); + + // List lags delete too — retry until the app is gone. + must( + await findAppInList(state, app.appId, false), + `app ${app.appId} still present after delete (after retries)`, + ); + return `app ${app.appId} deleted`; +} + +export function printOrphanWarning( + state: State, + suspectIds: string[], + expectedName?: string, +): void { + process.stdout.write(`\n${COLOR.yellow}${COLOR.bold}⚠ ORPHAN APP WARNING${COLOR.reset}\n`); + process.stdout.write( + `${COLOR.yellow}The init wizard likely created an app but the script could not identify it.${COLOR.reset}\n`, + ); + if (expectedName) { + process.stdout.write( + `${COLOR.yellow}Expected app name: ${COLOR.bold}${expectedName}${COLOR.reset}${COLOR.yellow} (not found in list)${COLOR.reset}\n`, + ); + } + if (suspectIds.length > 0) { + process.stdout.write( + `${COLOR.yellow}Suspect app ids: ${suspectIds.join(', ')}${COLOR.reset}\n`, + ); + } + try { + const r = execOrThrow(brevoCmd(state), ['app', 'list', '--json'], state); + process.stdout.write(`${COLOR.yellow}Apps currently on the account:${COLOR.reset}\n`); + for (const a of listItems(parseJson(r.stdout))) { + const id = pickId(a) || '?'; + const name = typeof a.name === 'string' ? a.name : '?'; + const flag = name.startsWith('brevo-cli-smoke') + ? ` ${COLOR.red}← likely smoke leak${COLOR.reset}` + : ''; + process.stdout.write(` - ${id} ${name}${flag}\n`); + } + process.stdout.write( + `${COLOR.yellow}Delete any that look like smoke artifacts with:${COLOR.reset}\n` + + ` ${COLOR.dim}brevo app delete --app-id --force${COLOR.reset}\n`, + ); + } catch (e) { + logToFile(state, `orphan listing failed: ${errMsg(e)}`); + } +} + +// Runs as a normal step, deliberately placed before Logout and Final cleanup. +// Those two steps destroy the two things a delete needs — the credentials and +// the linked `brevo` binary — so the trap-based safety net that runs after them +// can't actually clean anything up. Any app left behind by a delete step that +// failed earlier (a rate limit, a transient 5xx) has to be caught here, while +// the session is still alive. +export function stepDeleteLeftoverApps(state: State): string { + const leftovers: Array<{ label: string; appId: string; clear: () => void }> = []; + if (state.mainApp) { + leftovers.push({ + label: 'private', + appId: state.mainApp.appId, + clear: () => (state.mainApp = null), + }); + } + if (state.publicApp) { + leftovers.push({ + label: 'public', + appId: state.publicApp.appId, + clear: () => (state.publicApp = null), + }); + } + if (state.initAppId) { + leftovers.push({ + label: 'init', + appId: state.initAppId, + clear: () => (state.initAppId = null), + }); + } + if (leftovers.length === 0) return 'nothing left behind'; + + const deleted: string[] = []; + const failed: string[] = []; + for (const { label, appId, clear } of leftovers) { + // exec() already retries a rate-limited call, which is the most likely + // reason a delete step failed in the first place. + const r = exec( + brevoCmd(state), + ['app', 'delete', '--app-id', appId, '--force', '--json'], + state, + ); + if (r.exitCode === 0) { + deleted.push(`${label} ${appId}`); + clear(); + } else { + failed.push(`${label} ${appId} (exit ${r.exitCode})`); + } + } + + if (failed.length > 0) { + // Leave the ids on State so the trap reports them as orphans too. + const alsoDeleted = deleted.length > 0 ? `; deleted ${deleted.join(', ')}` : ''; + throw new Error(`could not delete ${failed.join(', ')}${alsoDeleted}`); + } + return `recovered leftover app(s): ${deleted.join(', ')}`; +} + +export function stepLogout(state: State): string { + execOrThrow(brevoCmd(state), ['logout', '--force', '--json'], state); + // whoami may exit non-zero when unauthenticated; accept either as "logged out" + const r = exec(brevoCmd(state), ['whoami', '--json'], state); + if (r.exitCode === 0) { + try { + const obj = parseJson>(r.stdout); + if (obj.authenticated || obj.user || obj.email) { + throw new Error('still authenticated after logout'); + } + } catch { + // unparseable whoami output post-logout is acceptable + } + } + return 'logged out'; +} + +export function killStartChild(state: State): void { + if (!state.startChild) return; + try { + // `.killed` only means a signal was already sent, not that the process + // exited — always send SIGKILL (a no-op on a dead pid) and drop the ref. + state.startChild.kill('SIGKILL'); + } catch { + // ignore + } + state.startChild = null; +} + +export function removeTmpDirs(state: State, logFailures: boolean): void { + for (const dir of state.tmpDirs) { + if (!existsSync(dir)) continue; + try { + rmSync(dir, { recursive: true, force: true }); + } catch (e) { + if (logFailures) { + logToFile(state, `rm ${dir} failed: ${errMsg(e)}`); + } + } + } + state.tmpDirs = []; + state.workRoot = null; +} + +export function stepFinalCleanup(state: State): string { + if (state.linked) { + if (state.opts.against === 'local') exec(PKG_YARN, ['unlink'], state); + else exec(PKG_NPM, ['uninstall', '-g', PACKAGE_NAME], state); + state.linked = false; + } + removeTmpDirs(state, true); + killStartChild(state); + return 'cleanup done'; +} + +// ──────────────────────────── trap cleanup ──────────────────────────── + +// Last-resort deletion. spawnSync does NOT throw on a non-zero exit, so the +// exit status must be checked explicitly — logging "deleted" off the back of a +// try/catch reports success for a delete that 401'd or got rate-limited, which +// is worse than no cleanup at all because nobody goes looking for the orphan. +export function trapDeleteApps(state: State): void { + const orphans: string[] = []; + for (const appId of [state.mainApp?.appId, state.publicApp?.appId, state.initAppId]) { + if (!appId) continue; + try { + const r = spawnSync( + brevoCmd(state), + ['app', 'delete', '--app-id', appId, '--force', '--json'], + { + timeout: 30_000, + encoding: 'utf8', + }, + ); + if (r.status === 0) { + logToFile(state, `trap: deleted app ${appId}`); + continue; + } + orphans.push(appId); + const why = firstLine(`${r.stderr ?? ''}\n${r.stdout ?? ''}`); + logToFile(state, `trap: FAILED to delete app ${appId} (exit ${r.status}): ${why}`); + } catch (e) { + orphans.push(appId); + logToFile(state, `trap: FAILED to delete app ${appId}: ${errMsg(e)}`); + } + } + state.mainApp = null; + state.publicApp = null; + state.initAppId = null; + + if (orphans.length > 0) { + state.orphanedAppIds.push(...orphans); + // Straight to stdout: by this point the run may be logged out and the CLI + // unlinked, so this is the only record the operator will see. + process.stdout.write( + `\n${COLOR.red}${COLOR.bold}⚠ ORPHANED APPS — CLEANUP FAILED${COLOR.reset}\n` + + `${COLOR.yellow}These apps could not be deleted and are still on the account:${COLOR.reset}\n` + + orphans.map((id) => ` ${id}\n`).join('') + + `${COLOR.yellow}Log in and remove them:${COLOR.reset}\n` + + orphans + .map((id) => ` ${COLOR.dim}brevo app delete --app-id ${id} --force${COLOR.reset}\n`) + .join(''), + ); + } +} + +// Goes through exec() rather than a bare spawnSync so the command name isn't a +// literal resolved off PATH at the call site (Sonar S4036), and so the output +// lands in the run log like every other subprocess call. +export function trapUninstallCli(state: State): void { + if (!state.linked) return; + const [cmd, args] = + state.opts.against === 'local' + ? [PKG_YARN, ['unlink']] + : [PKG_NPM, ['uninstall', '-g', PACKAGE_NAME]]; + try { + exec(cmd, args, state, { timeoutMs: 30_000 }); + } catch (e) { + logToFile(state, `trap: uninstall failed: ${errMsg(e)}`); + } + state.linked = false; +} + +// Best-effort: synchronous-ish, no throws. Runs on SIGINT/SIGTERM/uncaughtException +// and as a final safety net after the run loop. Designed to be idempotent. +export function bestEffortCleanup(state: State): void { + killStartChild(state); + trapDeleteApps(state); + removeTmpDirs(state, false); + trapUninstallCli(state); +} + +// ──────────────────────────── report ──────────────────────────── + +// A runnable group of steps. Each suite module exports one, and the runner +// composes whichever the caller selected. +export interface Suite { + name: string; + description: string; + steps: Array<[string, StepFn]>; +} diff --git a/scripts/smoke/init-wizard.ts b/scripts/smoke/init-wizard.ts new file mode 100644 index 0000000..d854a01 --- /dev/null +++ b/scripts/smoke/init-wizard.ts @@ -0,0 +1,121 @@ +/* + * The `brevo app init` wizard, driven through scripted stdin. Opt-in only. + */ + +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + State, + Suite, + brevoCmd, + errMsg, + execOrThrow, + execScriptedStdin, + findAppByName, + logToFile, + parseJson, + printOrphanWarning, + readJsonFile, + sleep, + stampedName, + trackTmpDir, +} from './core'; + +// Secondary appId recovery: if our unique name made it through, the app is +// identifiable even without parsing wizard output. Retry to absorb +// list-endpoint propagation lag. +async function findInitAppByName(state: State, expectedName: string): Promise { + for (let attempt = 0; attempt < 4; attempt++) { + const after = execOrThrow(brevoCmd(state), ['app', 'list', '--json'], state); + const found = findAppByName(parseJson(after.stdout), expectedName); + if (found) return found; + if (attempt < 3) await sleep([500, 1000, 2000][attempt] ?? 2000); + } + return null; +} + +// Tertiary appId recovery: read app-config.json (only present if user +// scaffolded in wizard, which our scripted answers explicitly decline — but +// keep as a safety net in case the wizard flow changes). +function readInitAppIdFromConfig(state: State, tmp: string): string | null { + const cfgPath = join(tmp, 'app-config.json'); + if (!existsSync(cfgPath)) return null; + try { + const cfg = readJsonFile(cfgPath); + // Narrow before stringifying — `appId` is unknown here, and an object would + // stringify to "[object Object]" and then be used as an app id. + const rawId = cfg.appId; + if (typeof rawId === 'string' || typeof rawId === 'number') return String(rawId); + } catch (e) { + logToFile(state, `app-config.json parse failed: ${errMsg(e)}`); + } + return null; +} + +async function stepInitWizard(state: State): Promise { + const tmp = trackTmpDir(state, 'brevo-smoke-init-'); + + // Wizard prompts (must stay in sync with `brevo app init` flow): + // 1. App name → unique, readable, traceable name + // 2. Distribution type → '' = accept default (Private) + // 3. OAuth callback → '' = accept default + // 4. Add another? → n + // 5. Generate starter? → n (scaffold has its own step) + const expectedName = stampedName(state, 'init'); + const answers = [expectedName, '', '', 'n', 'n']; + + // Paced writes: spawnSync(input:) closes stdin immediately on EOF and + // inquirer reads ahead of its prompts before then, defaulting prompts that + // had no answer yet. Use execScriptedStdin which writes lines one at a time + // with a short delay so inquirer reads each answer as its prompt renders. + const r = await execScriptedStdin(brevoCmd(state), ['app', 'init'], state, { + cwd: tmp, + answers, + interLineDelayMs: 400, + }); + if (r.exitCode !== 0) throw new Error(`brevo app init exited ${r.exitCode}`); + const output = r.stdout; + + // Primary: parse "App ID: " from wizard output. UUID format only — the + // wizard prints other ids (Client ID is 32 hex) which we explicitly don't match. + const uuidPattern = /App ID:\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i; + let appId: string | null = uuidPattern.exec(output)?.[1] ?? null; + + if (!appId) appId = await findInitAppByName(state, expectedName); + if (!appId) appId = readInitAppIdFromConfig(state, tmp); + + // NO list-diff fallback. A blind "delete the first new app" path could + // remove an app a human (or another process) just created on the same + // account. If we can't identify our app by parsing wizard output, by our + // exact unique name, or via app-config.json, we refuse to guess — the + // orphan warning prints the suggested cleanup commands and the step fails. + if (!appId) { + printOrphanWarning(state, [], expectedName); + throw new Error( + `could not identify init-created app (expected name "${expectedName}"); refusing to guess. See orphan warning above for manual cleanup.`, + ); + } + + state.initAppId = appId; + return `init created app ${appId} in ${tmp}`; +} + +function stepDeleteInitApp(state: State): string { + if (!state.initAppId) throw new Error('no initAppId to delete'); + const id = state.initAppId; + execOrThrow(brevoCmd(state), ['app', 'delete', '--app-id', id, '--force', '--json'], state); + state.initAppId = null; + return `app ${id} deleted`; +} + +// ──────────────────────────── teardown ──────────────────────────── + +export const initWizardSuite: Suite = { + name: 'init', + description: '`brevo app init` wizard', + steps: [ + ['brevo app init wizard', stepInitWizard], + ['Delete init-created app', stepDeleteInitApp], + ], +}; diff --git a/scripts/smoke/private-app.ts b/scripts/smoke/private-app.ts new file mode 100644 index 0000000..906212b --- /dev/null +++ b/scripts/smoke/private-app.ts @@ -0,0 +1,319 @@ +/* + * Private-app lifecycle: create -> credentials -> upload -> verify rename -> + * scaffold -> start -> delete, plus the client-side negative probes. + */ + +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; + +import { + State, + Suite, + asStringArray, + assertMappedFailure, + assertPortFree, + brevoCmd, + createSmokeApp, + deleteSmokeApp, + ensureWorkRoot, + exec, + execOrThrow, + findAppByName, + logToFile, + must, + optStr, + parseJson, + probeHttp, + renamedName, + requireApp, + requireCommand, + requireProjectDir, + sameSet, + sleep, + uploadApp, + waitForExit, +} from './core'; + +async function stepAppCreate(state: State): Promise { + const app = await createSmokeApp(state, { label: 'test', distribution: 'private' }); + return `private app ${app.appId} created in ${app.projectDir}, listed`; +} + +function stepAppCredentials(state: State): string { + const app = requireApp(state.mainApp, 'private'); + const creds = execOrThrow( + brevoCmd(state), + ['app', 'credentials', '--app-id', app.appId, '--reveal-secret', '--json'], + state, + ); + const credObj = parseJson>(creds.stdout); + if (!credObj.clientId || !credObj.clientSecret) { + throw new Error('credentials response missing clientId or clientSecret'); + } + return `clientId + clientSecret returned`; +} + +function stepAppUpload(state: State): string { + requireCommand(state, 'upload'); + const app = requireApp(state.mainApp, 'private'); + const res = uploadApp(state, app); + return `renamed + redirect URL added, version ${optStr(res.version)}`; +} + +// Re-running upload with nothing changed must report up-to-date and push +// nothing. The version is the one field the server may bump on its own, so a +// version-only difference is accepted (and reported) rather than failed. +function stepAppUploadNoop(state: State): string { + requireCommand(state, 'upload'); + const app = requireApp(state.mainApp, 'private'); + const res = parseJson>( + execOrThrow(brevoCmd(state), ['app', 'upload', '--yes', '--json'], state, { + cwd: requireProjectDir(app), + }).stdout, + ); + if (res.upToDate === true) return `up to date at version ${optStr(res.version)}`; + + const current = (res.current ?? {}) as Record; + const next = (res.next ?? {}) as Record; + must( + current.name === next.name, + `second upload changed the name: ${JSON.stringify(current.name)} → ${JSON.stringify(next.name)}`, + ); + must( + sameSet( + asStringArray(current.redirect_uris, 'current.redirect_uris'), + asStringArray(next.redirect_uris, 'next.redirect_uris'), + ), + 'second upload changed the redirect URLs', + ); + return `no-op upload pushed only a version change (${JSON.stringify(current.version)} → ${JSON.stringify(next.version)})`; +} + +// Verifies the effect of the upload step, so it shares its gates. +async function stepVerifyRename(state: State): Promise { + requireCommand(state, 'upload'); + const app = requireApp(state.mainApp, 'private'); + requireProjectDir(app); + const expected = renamedName(app); + + // Confirm the rename persisted server-side. The list endpoint is eventually + // consistent (see findAppInList), so poll with backoff before declaring miss. + const renameBackoff = [500, 1000, 2000, 4000]; + for (let i = 0; i < renameBackoff.length; i++) { + const r = execOrThrow(brevoCmd(state), ['app', 'list', '--json'], state); + if (findAppByName(parseJson(r.stdout), expected) === app.appId) { + return `rename visible in list as "${expected}"`; + } + if (i < renameBackoff.length - 1) await sleep(renameBackoff[i] ?? 4000); + } + throw new Error( + `renamed app ${app.appId} (${expected}) not present in list after upload (after retries)`, + ); +} + +const OAUTH_FEATURE_FILES = [ + join('src', 'oauth', 'server.js'), + join('src', 'oauth', 'handler.js'), + join('src', 'oauth', 'package.json'), +]; + +// `brevo app scaffold` adds a feature to the project in the cwd — it has no +// --app-id flag (it reads app-config.json), so it must run inside the directory +// `brevo app create` produced. +function stepScaffold(state: State): string { + const app = requireApp(state.mainApp, 'private'); + const projectDir = requireProjectDir(app); + const result = execOrThrow(brevoCmd(state), ['app', 'scaffold', '--json'], state, { + cwd: projectDir, + }); + const parsed = parseJson>(result.stdout); + + // --json can't answer the "refresh local config?" prompt, so it cancels and + // reports the drift instead. Right after an upload there should be none — + // if there is, that's a real local-vs-server bug, so surface the diffs. + must( + parsed.cancelled !== true, + `scaffold cancelled: ${JSON.stringify(parsed.diffs ?? parsed.reason ?? {})}`, + ); + must( + typeof parsed.scaffolded === 'number' && parsed.scaffolded > 0, + `scaffold wrote no files: ${JSON.stringify(parsed)}`, + ); + const missing = OAUTH_FEATURE_FILES.filter((f) => !existsSync(join(projectDir, f))); + must(missing.length === 0, `scaffold did not write ${missing.join(', ')}`); + return `oauth feature scaffolded into ${projectDir} (${String(parsed.scaffolded)} files)`; +} + +async function stepStartBriefly(state: State): Promise { + const app = requireApp(state.mainApp, 'private'); + const dir = requireProjectDir(app); + await assertPortFree(state.opts.port); + + // The scaffold puts the feature's package.json inside src/oauth/ (see + // src/templates/index.ts) and there is no root package.json. `brevo app start + // oauth` rejects with "Dependencies not installed" unless src/oauth/node_modules + // exists, so install there — and only there. + const featureDir = join(dir, 'src', 'oauth'); + must(existsSync(join(featureDir, 'package.json')), `no package.json in ${featureDir}`); + execOrThrow('yarn', ['install'], state, { cwd: featureDir }); + + const child = spawn( + brevoCmd(state), + ['app', 'start', 'oauth', '--port', String(state.opts.port)], + { + cwd: dir, + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + }, + ); + state.startChild = child; + let lastOutput = ''; + let earlyExit: number | null = null; + child.stdout?.on('data', (d) => { + lastOutput += d.toString(); + logToFile(state, '[start] ' + d.toString().trimEnd()); + }); + child.stderr?.on('data', (d) => { + lastOutput += d.toString(); + logToFile(state, '[start-err] ' + d.toString().trimEnd()); + }); + child.on('exit', (code) => { + earlyExit = code; + }); + + // Poll for the server, but bail out early if the child has already exited + // (e.g. missing app-config.json, port conflict surfaced inside the child). + const timeoutMs = state.opts.ci ? 5000 : 10000; + const deadline = Date.now() + timeoutMs; + let ok = false; + while (Date.now() < deadline) { + if (earlyExit !== null) break; + if (await probeHttp(state.opts.port)) { + ok = true; + break; + } + await sleep(250); + } + if (earlyExit === null) child.kill('SIGTERM'); + await waitForExit(child, 3000); + state.startChild = null; + + if (!ok) { + const tail = lastOutput.trim().split('\n').slice(-3).join(' | '); + const cause = + earlyExit === null + ? `server did not respond on port ${state.opts.port} within ${timeoutMs}ms` + : `child exited ${earlyExit} before serving: ${tail}`; + throw new Error(cause); + } + return `server booted on port ${state.opts.port}`; +} + +// Client-side guardrails: every probe here fails before any API call, so these +// assertions are exact — the mapped message from src/lang/en.ts and the exit +// code from src/lib/exit-codes.ts, with no backend involvement. +function stepNegativeClientGuardrails(state: State): string { + const workRoot = ensureWorkRoot(state); + const details: string[] = []; + + details.push( + assertMappedFailure( + exec( + brevoCmd(state), + ['app', 'create', '--name', 'brevo-cli-smoke-invalid', '--distribution', 'bogus', '--json'], + state, + { cwd: workRoot }, + ), + { + what: 'create --distribution bogus', + patterns: [/Invalid --distribution "bogus"\. Must be one of: private, public\./], + exitCodes: [1], + }, + ), + ); + + if (state.caps?.upload !== false) { + details.push( + assertMappedFailure( + exec(brevoCmd(state), ['app', 'upload', '--json'], state, { cwd: workRoot }), + { + what: 'upload with no app-config.json', + patterns: [/No app-config\.json found in this directory/], + exitCodes: [1], + }, + ), + ); + } + + if (state.caps?.submit !== false) { + details.push( + assertMappedFailure( + exec(brevoCmd(state), ['app', 'submit', '--json'], state, { cwd: workRoot }), + { + what: 'submit with no resolvable app', + patterns: [/Cannot determine which app to submit/], + exitCodes: [1], + }, + ), + ); + } + + return details.join('; '); +} + +// Only public apps are eligible for review (BEX-254 mapping). The private app +// from the earlier steps is the natural subject, and this runs from the work +// root so submit can't pick the app up from an app-config.json. +function stepNegativeSubmitPrivate(state: State): string { + requireCommand(state, 'submit'); + const app = requireApp(state.mainApp, 'private'); + const r = exec(brevoCmd(state), ['app', 'submit', '--app-id', app.appId, '--json'], state, { + cwd: ensureWorkRoot(state), + }); + return assertMappedFailure(r, { + what: 'submit a private app', + patterns: [ + // What the CLI would say if it got as far as its own check. + /is private\. Private apps cannot be submitted for review/, + // What actually happens today (verified against the live API): submit + // preflights the review state before checking distribution_type (see + // checkAppStatus in submit.ts), and the server refuses that read for a + // private app — so the CLI surfaces the server's shorter string and its + // own APP_SUBMIT_NOT_PUBLIC copy is never reached. Accepted here rather + // than failed, because the refusal itself is correct; the message + // ordering is a CLI-side issue, recorded in the repo-root follow-up list. + /not supported for private apps/, + // A backend with no submission record at all answers 404 on that read, + // which maps to the not-found message and exit 5. + /not found\./, + ], + exitCodes: [1, 5], + }); +} + +async function stepDeleteMainApp(state: State): Promise { + const app = requireApp(state.mainApp, 'private'); + const detail = await deleteSmokeApp(state, app); + state.mainApp = null; + return detail; +} + +// ──────────────────────────── public-app lifecycle ──────────────────────────── + +export const privateAppSuite: Suite = { + name: 'private', + description: 'Private-app lifecycle and client-side guardrails', + steps: [ + ['App create', stepAppCreate], + ['App credentials', stepAppCredentials], + ['App upload', stepAppUpload], + ['App upload (no-op)', stepAppUploadNoop], + ['Verify rename', stepVerifyRename], + ['Scaffold', stepScaffold], + ['Start briefly', stepStartBriefly], + ['Negative: client guardrails', stepNegativeClientGuardrails], + ['Negative: submit a private app', stepNegativeSubmitPrivate], + ['Delete main test app', stepDeleteMainApp], + ], +}; diff --git a/scripts/smoke/public-app.ts b/scripts/smoke/public-app.ts new file mode 100644 index 0000000..1507d7b --- /dev/null +++ b/scripts/smoke/public-app.ts @@ -0,0 +1,308 @@ +/* + * Public-app review lifecycle: create -> upload -> status -> submit -> submit + * again -> status -> withdraw -> status -> delete, plus the unknown-app probes. + */ + +import { randomUUID } from 'node:crypto'; + +import { + FailureExpectation, + SmokeApp, + State, + Suite, + assertMappedFailure, + brevoCmd, + createSmokeApp, + deleteSmokeApp, + ensureWorkRoot, + exec, + execOrThrow, + firstLine, + must, + optStr, + parseJson, + requireApp, + requireCommand, + skip, + uploadApp, +} from './core'; + +// Every state src/lang/en.ts (APP_STATUS_MESSAGE) has canned copy for, plus the +// 'unknown' sentinel status.ts normalises an empty state to. An unrecognised +// value means the server grew a state the CLI doesn't describe yet. +const KNOWN_REVIEW_STATES = [ + 'unknown', + 'configured', + 'submitted', + 'in_review', + 'approved', + 'rejected', + 'changes_requested', +]; + +const SUBMITTED_STATES = new Set(['submitted', 'in_review']); + +const SMOKE_LOGO_URI = 'https://example.com/logo.png'; + +async function stepPublicAppCreate(state: State): Promise { + // --distribution public is accepted since BEX-327; the old negative step that + // asserted the CLI rejected it has been removed. --logo-uri exercises the + // optional create field from BEX-255 in the same call. + const app = await createSmokeApp(state, { + label: 'public', + distribution: 'public', + logoUri: SMOKE_LOGO_URI, + }); + return `public app ${app.appId} created in ${app.projectDir}, listed`; +} + +function stepPublicAppUpload(state: State): string { + requireCommand(state, 'upload'); + const app = requireApp(state.publicApp, 'public'); + const res = uploadApp(state, app); + const next = (res.next ?? {}) as Record; + must( + next.distribution_type === 'public', + `upload next.distribution_type ${JSON.stringify(next.distribution_type)} != public`, + ); + return `public app uploaded, version ${optStr(res.version)}`; +} + +// Read the review state through `brevo app status --json`. The state itself is +// the backend's to decide, so it's recorded, not dictated — what's asserted is +// that the CLI returns a state it has copy for, with a message. +function readReviewState(state: State, app: SmokeApp): string { + const r = execOrThrow(brevoCmd(state), ['app', 'status', '--app-id', app.appId, '--json'], state); + const parsed = parseJson>(r.stdout); + const reviewState = parsed.state; + must( + typeof reviewState === 'string' && reviewState.length > 0, + `status --json has no state: ${JSON.stringify(parsed)}`, + ); + must( + typeof parsed.message === 'string' && parsed.message.length > 0, + `status --json has no message: ${JSON.stringify(parsed)}`, + ); + must( + KNOWN_REVIEW_STATES.includes(reviewState as string), + `status returned state "${String(reviewState)}", which the CLI has no copy for (known: ${KNOWN_REVIEW_STATES.join(', ')})`, + ); + return reviewState as string; +} + +function stepPublicAppStatus(state: State): string { + requireCommand(state, 'status'); + const app = requireApp(state.publicApp, 'public'); + const reviewState = readReviewState(state, app); + state.publicObs.stateBeforeSubmit = reviewState; + return `pre-submit state: ${reviewState}`; +} + +// `brevo app submit` does not itself transition the app — it validates the app +// (public, in sync with app-config.json) and hands back the review form URL; +// the state only moves once the form is submitted. So this asserts everything +// the CLI owns and records the URL. +function stepPublicAppSubmit(state: State): string { + requireCommand(state, 'submit'); + const app = requireApp(state.publicApp, 'public'); + // Run from the project dir so the local-vs-server drift check is exercised; + // straight after an upload it must come back clean. Without a project dir + // (older build) submit still works from --app-id alone, minus the drift check. + const r = exec(brevoCmd(state), ['app', 'submit', '--app-id', app.appId, '--json'], state, { + cwd: app.projectDir || ensureWorkRoot(state), + }); + + if (r.exitCode !== 0) { + const text = `${r.stderr}\n${r.stdout}`; + // No review form link on the app means the backend isn't serving one for + // this account yet (pre-GA) — there's nothing for the CLI to do, so skip + // loudly instead of reporting a CLI failure. Anything else, including a + // config mismatch right after a clean upload, is a real failure. + if (/Review submission is currently unavailable/.test(text)) { + skip(`backend returned no review form link for app ${app.appId} (${firstLine(text)})`); + } + throw new Error(`submit failed with exit ${r.exitCode}: ${firstLine(text)}`); + } + + const parsed = parseJson>(r.stdout); + must( + String(parsed.app_id) === app.appId, + `submit returned app_id ${JSON.stringify(parsed.app_id)} != ${app.appId}`, + ); + const formUrl = parsed.form_url; + must( + typeof formUrl === 'string' && /^https?:\/\//.test(formUrl), + `submit returned no usable form_url: ${JSON.stringify(formUrl)}`, + ); + state.publicObs.formUrl = formUrl as string; + return `submit returned a review form URL for app ${app.appId}`; +} + +// Submitting the same app twice. The CLI's submit is a form hand-off rather +// than a state transition, so a server-side "already submitted" rejection can't +// be produced from the CLI alone — what's asserted is that the second call is +// either idempotent (same form URL) or refused with the mapped +// already-under-review message, never an unmapped error. +function stepPublicAppSubmitAgain(state: State): string { + requireCommand(state, 'submit'); + const app = requireApp(state.publicApp, 'public'); + if (!state.publicObs.formUrl) skip('first submit did not run, nothing to repeat'); + + const r = exec(brevoCmd(state), ['app', 'submit', '--app-id', app.appId, '--json'], state, { + cwd: app.projectDir || ensureWorkRoot(state), + }); + if (r.exitCode === 0) { + const parsed = parseJson>(r.stdout); + must( + parsed.form_url === state.publicObs.formUrl, + `repeat submit returned a different form_url than the first call`, + ); + return 'repeat submit is idempotent (same form URL, exit 0)'; + } + return assertMappedFailure(r, { + what: 'repeat submit', + patterns: [/Review submission is currently unavailable/], + exitCodes: [1], + }); +} + +function stepPublicAppStatusAfterSubmit(state: State): string { + requireCommand(state, 'status'); + const app = requireApp(state.publicApp, 'public'); + const reviewState = readReviewState(state, app); + state.publicObs.stateAfterSubmit = reviewState; + const before = state.publicObs.stateBeforeSubmit; + // A transition only happens if the backend moves the app when the review form + // link is issued — it usually waits for the form itself, so report either way. + const moved = before && before !== reviewState ? ` (was ${before})` : ' (unchanged)'; + return `post-submit state: ${reviewState}${moved}`; +} + +// `brevo app withdraw` has two documented success shapes (src/commands/app/withdraw.ts): +// an actual withdrawal, or the mapped HTTP 422 "not submitted yet" report which +// is informational and deliberately exits 0. Assert whichever applies, then +// re-read the state. +function stepPublicAppWithdraw(state: State): string { + requireCommand(state, 'withdraw'); + const app = requireApp(state.publicApp, 'public'); + const r = exec( + brevoCmd(state), + ['app', 'withdraw', '--app-id', app.appId, '--force', '--json'], + state, + ); + const combinedOutput = `${r.stderr}\n${r.stdout}`; + must( + r.exitCode === 0, + `withdraw exited ${r.exitCode} (both documented outcomes exit 0): ${firstLine(combinedOutput)}`, + ); + const parsed = parseJson>(r.stdout); + must( + String(parsed.appId) === app.appId, + `withdraw returned appId ${JSON.stringify(parsed.appId)} != ${app.appId}`, + ); + + if (parsed.withdrawn === true) { + state.publicObs.withdrawn = true; + return `app ${app.appId} withdrawn`; + } + + must( + parsed.withdrawn === false, + `withdraw --json has no boolean "withdrawn": ${JSON.stringify(parsed)}`, + ); + must( + parsed.reason === 'NOT_SUBMITTED', + `withdraw reported withdrawn:false with reason ${JSON.stringify(parsed.reason)}, expected NOT_SUBMITTED`, + ); + must( + typeof parsed.message === 'string' && /has not been submitted yet/.test(parsed.message), + `withdraw NOT_SUBMITTED message is not the mapped one: ${JSON.stringify(parsed.message)}`, + ); + must( + typeof parsed.submitCommand === 'string' && parsed.submitCommand.includes('brevo app submit'), + `withdraw NOT_SUBMITTED did not include the submit hint: ${JSON.stringify(parsed.submitCommand)}`, + ); + state.publicObs.withdrawn = false; + state.publicObs.withdrawReason = 'NOT_SUBMITTED'; + return `not-submitted app mapped to NOT_SUBMITTED (exit 0, by design)`; +} + +function stepPublicAppStatusAfterWithdraw(state: State): string { + requireCommand(state, 'status'); + const app = requireApp(state.publicApp, 'public'); + const reviewState = readReviewState(state, app); + state.publicObs.stateAfterWithdraw = reviewState; + // Only a real withdrawal implies a state change; a NOT_SUBMITTED no-op must + // leave the app exactly where it was. + if (state.publicObs.withdrawn === true) { + must( + !SUBMITTED_STATES.has(reviewState), + `app is still "${reviewState}" after a successful withdraw`, + ); + } else if (state.publicObs.stateAfterSubmit) { + must( + reviewState === state.publicObs.stateAfterSubmit, + `no-op withdraw changed the state: ${state.publicObs.stateAfterSubmit} → ${reviewState}`, + ); + } + return `post-withdraw state: ${reviewState}`; +} + +async function stepDeletePublicApp(state: State): Promise { + const app = requireApp(state.publicApp, 'public'); + const detail = await deleteSmokeApp(state, app); + state.publicApp = null; + return detail; +} + +// ──────────────────────────── init wizard ──────────────────────────── + +// Unknown app IDs must map to the friendly not-found message, not a raw HTTP +// error. Uses a random UUID that cannot exist on the account. +function stepNegativeUnknownApp(state: State): string { + const fakeId = randomUUID(); + const details: string[] = []; + const expectation = (what: string): FailureExpectation => ({ + what, + patterns: [/not found\./i, /don't have access|access denied|not authorized/i], + // 404 → NOT_FOUND (5). A backend that answers 403 for someone else's app + // maps to ERROR (1) — see statusToExitCode in src/lib/errors.ts. + exitCodes: [1, 5], + }); + + if (state.caps?.status !== false) { + details.push( + assertMappedFailure( + exec(brevoCmd(state), ['app', 'status', '--app-id', fakeId, '--json'], state), + expectation('status on an unknown app'), + ), + ); + } + if (state.caps?.withdraw !== false) { + details.push( + assertMappedFailure( + exec(brevoCmd(state), ['app', 'withdraw', '--app-id', fakeId, '--force', '--json'], state), + expectation('withdraw on an unknown app'), + ), + ); + } + if (details.length === 0) skip('neither app status nor app withdraw is available in this build'); + return details.join('; '); +} + +export const publicAppSuite: Suite = { + name: 'public', + description: 'Public-app submission and review lifecycle', + steps: [ + ['Public app create', stepPublicAppCreate], + ['Public app upload', stepPublicAppUpload], + ['Public app status', stepPublicAppStatus], + ['Public app submit', stepPublicAppSubmit], + ['Public app submit (repeat)', stepPublicAppSubmitAgain], + ['Public app status after submit', stepPublicAppStatusAfterSubmit], + ['Public app withdraw', stepPublicAppWithdraw], + ['Public app status after withdraw', stepPublicAppStatusAfterWithdraw], + ['Negative: unknown app id', stepNegativeUnknownApp], + ['Delete public test app', stepDeletePublicApp], + ], +};