diff --git a/ERRORS.md b/ERRORS.md index a6926be..8fd9f49 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -140,13 +140,13 @@ Some connect-style operations may return `{ accounts: [...], failures: [...] }` ### Browser-opening operations -Commands that generate an install / consent URL (`auth login`, `integration connect --type `, `cloud connect --provider `, …) always print the URL to stdout and, in interactive mode, also try to open the browser. They succeed whether or not the browser actually opens. When the command can wait for the upstream side to finish (`integration connect`, `cloud connect`), the exit code reflects that wait: `0` once the connection shows up, `1` when the wait timed out, `7` when `cloud connect --provider aws` ends while the CloudFormation stack is still creating (the account arrives later; `polylane cloud list` shows it). After a browser flow, re-query state with the relevant `list` / `show` command to confirm. +Commands that generate an install / consent URL (`auth login`, `integration connect --type `, `cloud connect --provider `, …) always print the URL to stdout and, in interactive mode, also try to open the browser. They succeed whether or not the browser actually opens. When the command can wait for the upstream side to finish (`integration connect`, `cloud connect`), the exit code reflects that wait: `0` once the connection shows up and `1` when the foreground wait times out. Explicit `cloud connect --provider aws` keeps its 15-minute foreground wait. The interactive provider picker instead watches each submitted AWS account independently while more accounts are added; choosing Done stops those local pollers immediately and exits `7` if any remain pending. After a browser flow, re-query state with the relevant `list` / `show` command to confirm. ### Pending (exit `7`) | Scenario | Exit | Typical message | |---|---|---| -| `cloud connect --provider aws` ends before the CloudFormation stack finishes creating | 7 | `AWS is still connecting — the CloudFormation stack has not shown up yet.` with a hint to check `polylane cloud list` | +| Done is chosen in the interactive `cloud connect` provider picker before one or more AWS CloudFormation stacks finish creating | 7 | One status per pending 12-digit account, with `polylane cloud list` check and account-specific `cloud connect` retry commands | | `subscription upgrade` (or the upgrade offer inside `cloud connect`): Stripe reported the payment but the workspace plan had not changed after 90 s | 7 | `Payment received; your plan is updating.` with a hint to re-check (`polylane subscription show`, or re-run `polylane cloud connect`) | `7` is not an error: the launch went through and nothing needs to be re-run unless the stack fails. Treat it as "not connected yet" and re-check with `polylane cloud list` before depending on the account. diff --git a/src/commands/cloud/connect.ts b/src/commands/cloud/connect.ts index 6e9f450..7715174 100644 --- a/src/commands/cloud/connect.ts +++ b/src/commands/cloud/connect.ts @@ -92,6 +92,18 @@ export function parseAwsRegions(value: string): string[] | null { return all ? null : regions; } +export function parseAwsAccountId(value: string): string { + const accountId = value.trim(); + if (!/^\d{12}$/.test(accountId)) { + throw new CLIError( + `Invalid 12-digit AWS account ID: "${value}"`, + ExitCode.USAGE, + 'Pass the 12-digit AWS account ID, for example --account 123456789012' + ); + } + return accountId; +} + const AWS_REGIONS = [ { value: AWS_ALL_REGIONS, label: 'All regions (every region enabled on the account, now and later)' }, { value: 'us-east-1', label: 'us-east-1 (N. Virginia)' }, @@ -121,15 +133,22 @@ export type ConnectOutcome = HandoffOutcome | 'pending'; export function connectExitCode( outcome: HandoffOutcome | null, - awsOutcome: Extract | null + awsOutcome: + | Extract + | ReadonlyArray> + | null ): ExitCode { if (outcome === 'timeout') return ExitCode.GENERAL; - if (awsOutcome === 'pending') return ExitCode.PENDING; + const awsOutcomes = Array.isArray(awsOutcome) ? awsOutcome : awsOutcome === null ? [] : [awsOutcome]; + if (awsOutcomes.includes('pending')) return ExitCode.PENDING; return ExitCode.SUCCESS; } -export interface AccountBaseline { +export interface AccountSnapshot { existing: CloudAccount[]; +} + +export interface AccountBaseline extends AccountSnapshot { check: () => Promise; } @@ -151,6 +170,52 @@ export async function accountBaseline( return { existing: before.items, check }; } +export function accountCheckForAwsId( + baseline: AccountSnapshot, + accountId: string, + listCurrent: () => Promise +): () => Promise { + const seen = new Map( + baseline.existing + .filter((account) => account.account === accountId) + .map((account) => [account.id, account.updated ?? '']) + ); + return async (): Promise => { + const current = (await listCurrent()).filter((account) => account.account === accountId); + const fresh = current.filter((account) => !seen.has(account.id) || seen.get(account.id) !== (account.updated ?? '')); + return fresh.length > 0 ? fresh : null; + }; +} + +export function connectedAwsAccountsForId(existing: CloudAccount[], accountId: string): CloudAccount[] { + return existing.filter((account) => account.account === accountId); +} + +export type AwsSubmissionPreparation = + | { kind: 'pending' } + | { kind: 'connected'; accounts: CloudAccount[] } + | { kind: 'submit'; baseline: AccountSnapshot }; + +export async function prepareAwsSubmission( + accountId: string, + reconnect: boolean, + waits: AwsStackWaits, + listCurrent: () => Promise +): Promise { + if (waits.has(accountId)) return { kind: 'pending' }; + const existing = await listCurrent(); + const matchingExisting = connectedAwsAccountsForId(existing, accountId); + if (!reconnect && matchingExisting.length > 0) return { kind: 'connected', accounts: matchingExisting }; + return { + kind: 'submit', + baseline: { existing }, + }; +} + +export function useAwsBackgroundPicker(config: Config, interactivePicker: boolean): boolean { + return interactivePicker && !config.dryRun && config.output !== 'json'; +} + function printAlreadyConnected(config: Config, name: string, accounts: CloudAccount[]): void { if (config.output === 'json') { formatOutput(config, { accounts }); @@ -195,34 +260,24 @@ function accountLabel(account: CloudAccount): string { } const AWS_WAIT_INTERVAL_MS = 5_000; -const AWS_SETTLE_TIMEOUT_MS = 2 * 60_000; const AWS_CHECK_HINT = 'Check with `polylane cloud list`.'; -const AWS_STILL_CONNECTING = - 'AWS is still connecting — the CloudFormation stack has not shown up yet.\n' + - 'Check later with `polylane cloud list`; the account appears there once the\n' + - 'stack finishes creating. If the stack failed or rolled back, your AWS\n' + - 'CloudFormation console shows the reason; fix it and re-run\n' + - '`polylane cloud connect --provider aws`. Exiting 7 (pending) until then.'; export interface AwsStackWait { + accountId: string; pending: () => boolean; flush: () => void; - settle: () => Promise>; + stop: () => Extract; } -// The picker flow hands the CloudFormation deploy to this instead of blocking -// on it: polling runs in the background while the user keeps connecting other -// clouds, flush() prints the "AWS connected" transition between prompts (never -// over one), and settle() ends the session with a short bounded foreground -// wait so a stack that is still deploying reports its state instead of -// holding the terminal for the full deploy. +// The picker flow hands each CloudFormation deploy to one of these instead of +// blocking on it. Polling stays silent while a prompt is active, flush() prints +// the transition between prompts, and stop() makes Done immediate. export function startAwsStackWait( - config: Config, + accountId: string, check: () => Promise, - opts: { settleTimeoutMs?: number; intervalMs?: number } = {} + opts: { intervalMs?: number } = {} ): AwsStackWait { const intervalMs = opts.intervalMs ?? AWS_WAIT_INTERVAL_MS; - const settleTimeoutMs = opts.settleTimeoutMs ?? AWS_SETTLE_TIMEOUT_MS; const background = startBackgroundCompletion(check, intervalMs); let reported: 'connected' | 'pending' | null = null; const reportConnected = (accounts: CloudAccount[]): 'connected' => { @@ -233,32 +288,86 @@ export function startAwsStackWait( return 'connected'; }; return { - pending: () => reported === null, + accountId, + pending: () => reported === null && background.peek() === null, flush: (): void => { if (reported !== null) return; const found = background.peek(); if (found) reportConnected(found); }, - settle: async (): Promise<'connected' | 'pending'> => { + stop: (): 'connected' | 'pending' => { if (reported !== null) return reported; background.stop(); const already = background.peek(); if (already) return reportConnected(already); - const found = await waitForBrowserCompletion(config, check, { - waitingFor: 'the CloudFormation stack to deploy (usually a few minutes)', - interruptHint: AWS_CHECK_HINT, - startHint: 'AWS: waiting for the CloudFormation stack to finish deploying.', - timeoutMs: settleTimeoutMs, - intervalMs, - }); - if (found) return reportConnected(found); reported = 'pending'; - process.stderr.write(`${AWS_STILL_CONNECTING}\n`); return 'pending'; }, }; } +export class AwsStackWaits { + private readonly waits = new Map(); + private submitted = 0; + + add(wait: AwsStackWait): boolean { + if (this.waits.has(wait.accountId)) { + wait.stop(); + return false; + } + this.waits.set(wait.accountId, wait); + this.submitted += 1; + return true; + } + + hasSubmitted(): boolean { + return this.submitted > 0; + } + + has(accountId: string): boolean { + const wait = this.waits.get(accountId); + if (!wait) return false; + wait.flush(); + if (wait.pending()) return true; + this.waits.delete(accountId); + return false; + } + + flush(): void { + for (const [accountId, wait] of this.waits) { + wait.flush(); + if (!wait.pending()) this.waits.delete(accountId); + } + } + + pendingAccountIds(): string[] { + return [...this.waits.keys()]; + } + + printPendingStatus(): void { + const pending = this.pendingAccountIds(); + if (pending.length > 0) process.stderr.write(`AWS pending: ${pending.join(', ')}\n`); + } + + finish(): Array> { + this.flush(); + const outcomes: Array> = []; + for (const [accountId, wait] of this.waits) { + const outcome = wait.stop(); + outcomes.push(outcome); + if (outcome === 'pending') { + process.stderr.write( + `AWS account ${accountId} is still connecting — the CloudFormation stack has not shown up yet.\n` + + `Check ${accountId}: \`polylane cloud list\` (look for account ${accountId})\n` + + `Retry ${accountId}: \`polylane cloud connect --provider aws --account ${accountId}\`\n` + ); + } + } + this.waits.clear(); + return outcomes; + } +} + type ConnectResult = Awaited>; export function printConnectSuccess(config: Config, result: ConnectResult): void { @@ -458,7 +567,8 @@ async function connectProvider( workspaceId: string, provider: Provider, noBrowser: boolean, - background: boolean + background: boolean, + awsWaits: AwsStackWaits ): Promise { const ctx = { nonInteractive: config.nonInteractive }; const reconnect = getArgBoolean(args, 'reconnect') === true; @@ -582,21 +692,9 @@ async function connectProvider( } let body: ConnectBody; - let awsBaseline: AccountBaseline | null = null; + let awsBaseline: AccountSnapshot | null = null; + let awsAccountId: string | null = null; if (provider === 'aws') { - // Short-circuit before the wizard when AWS is already connected — unless - // an explicit --account targets an account that is not connected yet. - awsBaseline = config.dryRun ? null : await accountBaseline(api, workspaceId, 'aws'); - const accountFlag = getArgString(args, 'account'); - if ( - awsBaseline && - !reconnect && - awsBaseline.existing.length > 0 && - (accountFlag === undefined || awsBaseline.existing.some((a) => a.account === accountFlag)) - ) { - printAlreadyConnected(config, 'AWS', awsBaseline.existing); - return 'connected'; - } let account = ''; let regions: string[] | null = null; let subscribeToAlarms = getArgBoolean(args, 'subscribeAlarms') === true; @@ -626,11 +724,27 @@ async function connectProvider( }, ]); if (!ok) return BACK; + awsAccountId = parseAwsAccountId(account); + if (!config.dryRun) { + const preparation = await prepareAwsSubmission(awsAccountId, reconnect, awsWaits, async () => { + const current = await api.cloudAccountsList(workspaceId, { provider: 'aws', perPage: 100 }); + return current.items; + }); + if (preparation.kind === 'pending') { + process.stderr.write(`AWS account ${awsAccountId} is already pending — finish its CloudFormation stack or pick Done.\n`); + return BACK; + } + if (preparation.kind === 'connected') { + printAlreadyConnected(config, 'AWS', preparation.accounts); + return 'connected'; + } + awsBaseline = preparation.baseline; + } const createMonitoringAlarms = getArgBoolean(args, 'createAlarms') === true; body = { workspaceId, provider: 'aws', - account, + account: awsAccountId, regions, ...(createMonitoringAlarms ? { createMonitoringAlarms } : {}), ...(subscribeToAlarms ? { subscribeToAlarms } : {}), @@ -844,9 +958,15 @@ async function connectProvider( } // AWS ends in the browser too (deploying the CloudFormation stack); the - // baseline snapshot from before the wizard lets the CLI wait for the - // account after. - const awsCheck = canWaitForBrowser(config) && awsBaseline ? awsBaseline.check : null; + // snapshot refreshed immediately before submission lets the CLI wait for + // this exact account without claiming another account's completion. + const awsCheck = + canWaitForBrowser(config) && awsBaseline && awsAccountId + ? accountCheckForAwsId(awsBaseline, awsAccountId, async () => { + const current = await api.cloudAccountsList(workspaceId, { provider: 'aws', perPage: 100 }); + return current.items; + }) + : null; const result = await api.cloudAccountsConnect(body); @@ -855,13 +975,14 @@ async function connectProvider( // is watched in the background so the rest of the session isn't blocked; // an explicit --provider aws keeps the foreground wait for scripts. if (result.provider === 'aws') { + if (awsAccountId === null) throw new Error('AWS connect completed without an account ID'); await openOrPrintInstallUrl(config, result.url, 'the AWS CloudFormation stack', noBrowser); if (background && awsCheck) { process.stderr.write('\nAWS: CloudFormation stack creating — finish it in your browser.\n'); if (!config.quiet) { process.stderr.write(`Polylane keeps checking in the background while you continue. ${AWS_CHECK_HINT}\n`); } - return startAwsStackWait(config, awsCheck); + return startAwsStackWait(awsAccountId, awsCheck); } return confirmBrowserConnect(config, awsCheck, 'the CloudFormation stack to deploy (usually a few minutes)', { timeoutMs: 15 * 60_000, @@ -949,16 +1070,18 @@ export const cloudConnectCommand: Command = { const api = new PolylaneAPI(config); const providerFromFlag = getArgString(args, 'provider') !== undefined; const interactivePicker = !providerFromFlag && isInteractive(config.nonInteractive); + const awsBackgroundPicker = useAwsBackgroundPicker(config, interactivePicker); // Provider selection restarts whenever the user backs out of the first // step of the chosen flow, so nothing is committed until a flow completes. // While an AWS CloudFormation deploy runs in the background the picker // stays open so other clouds can connect meanwhile; its transitions are // flushed between prompts and the session settles them before it ends. - let awsWait: AwsStackWait | null = null; + const awsWaits = new AwsStackWaits(); for (;;) { - awsWait?.flush(); - const exitLabel: string = awsWait !== null && awsWait.pending() ? 'Done' : 'Cancel'; + awsWaits.flush(); + awsWaits.printPendingStatus(); + const exitLabel = awsWaits.hasSubmitted() ? 'Done' : 'Cancel'; const provider: Provider | typeof BACK = !interactivePicker ? await promptChoice( @@ -977,25 +1100,21 @@ export const cloudConnectCommand: Command = { exitLabel ); if (provider === BACK) break; - if (provider === 'aws' && awsWait?.pending()) { - process.stderr.write('AWS: still waiting for the CloudFormation stack — connect another cloud or pick Done.\n'); - continue; - } - const outcome = await connectProvider(config, api, args, workspaceId, provider, noBrowser, interactivePicker); + const outcome = await connectProvider(config, api, args, workspaceId, provider, noBrowser, awsBackgroundPicker, awsWaits); if (outcome === BACK) { if (providerFromFlag) break; continue; } if (typeof outcome !== 'string') { - awsWait = outcome; + awsWaits.add(outcome); continue; } - const awsOutcome = awsWait ? await awsWait.settle() : null; - process.exitCode = connectExitCode(outcome, awsOutcome); + if (awsBackgroundPicker && provider === 'aws') continue; + process.exitCode = connectExitCode(outcome, awsWaits.finish()); return; } - if (awsWait) { - process.exitCode = connectExitCode(null, await awsWait.settle()); + if (awsWaits.hasSubmitted()) { + process.exitCode = connectExitCode(null, awsWaits.finish()); return; } cancel('Nothing connected.'); diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index f31b01e..bfc8332 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -66,7 +66,12 @@ type ConnectCategory = (typeof CONNECT_CATEGORIES)[number]; const TYPE_OPTIONS: Array<{ value: ConnectableType; label: string; hint: string; category: ConnectCategory }> = [ { value: 'github', label: 'GitHub', hint: 'install the GitHub App (browser)', category: 'git' }, - { value: 'slack', label: 'Slack', hint: 'install the Slack app (browser)', category: 'communication' }, + { + value: 'slack', + label: 'Slack', + hint: 'start investigations + receive incident/autofix updates in channels', + category: 'communication', + }, { value: 'sentry', label: 'Sentry', hint: 'install the Sentry integration (browser)', category: 'observability' }, { value: 'datadog', label: 'Datadog', hint: 'API + application keys', category: 'observability' }, { value: 'honeycomb', label: 'Honeycomb', hint: 'configuration API key', category: 'observability' }, diff --git a/src/commands/integration/slack-channels.ts b/src/commands/integration/slack-channels.ts index 3f5c972..86471c5 100644 --- a/src/commands/integration/slack-channels.ts +++ b/src/commands/integration/slack-channels.ts @@ -55,7 +55,10 @@ export function slackChannelOps(api: PolylaneAPI): SlackChannelOps | null { export const PICKER_SEARCH = '__search__'; export const PICKER_DONE = '__done__'; export const SLACK_CHANNELS_LATER_LINE = 'Add Polylane to Slack channels any time: polylane integration connect --type slack'; -const NO_CHANNELS_LINE = 'No public Slack channel to join yet. Polylane will suggest channels in Slack as they appear.'; +export const SLACK_CHANNEL_PICKER_NOTE = + 'Find and add public channels here. For private channels, invite @Polylane from the channel in Slack.'; +export const NO_SLACK_CHANNELS_LINE = + 'No public Slack channels are available here.'; const SEARCH_MIN_CHARS = 2; const SEARCH_MAX_RESULTS = 10; @@ -81,8 +84,12 @@ export function pickerOptions( } export function pickerMessage(selected: SlackChannelRef[]): string { - if (selected.length === 0) return 'Which Slack channels should Polylane join?'; - return `Selected: ${selected.map((c) => `#${c.name}`).join(', ')}. Add another, or Done.`; + if (selected.length === 0) return 'Which public channels should Polylane join?'; + return `Selected: ${selected.map((c) => `#${c.name}`).join(', ')}. Add another public channel, or Done.`; +} + +export function noSlackChannelMatchesLine(fragment: string): string { + return `No public Slack channel matching "${fragment.trim()}" is available here.`; } export function filterChannels(channels: SearchableChannel[], fragment: string, selectedIds: string[]): SearchableChannel[] { @@ -120,7 +127,7 @@ async function searchChannel( selected: SlackChannelRef[] ): Promise { const ctx = { nonInteractive: config.nonInteractive }; - const fragment = await promptTextOrBack(ctx, 'Channel name (at least two characters)'); + const fragment = await promptTextOrBack(ctx, 'Public channel name (at least two characters)'); if (fragment === BACK) return BACK; const spinner = new Spinner('Searching channels…'); spinner.start(); @@ -133,7 +140,7 @@ async function searchChannel( } const matches = filterChannels(channels, fragment, selected.map((c) => c.id)); if (matches.length === 0) { - say(`No public channel matching "${fragment.trim()}" that Polylane is not already in.`); + say(noSlackChannelMatchesLine(fragment)); return null; } const picked = await promptSelectOrBack( @@ -183,7 +190,7 @@ export async function runSlackChannelStep( if (!shouldOfferSlackChannels(true, opts.alreadyConnected, suggested.inAnyRealChannel)) return; note( - 'Polylane works in the channels where alerts, deploys, and incidents land. Pick the ones it should join; you can add more later.', + SLACK_CHANNEL_PICKER_NOTE, 'Slack channels' ); const ctx = { nonInteractive: config.nonInteractive }; @@ -209,7 +216,7 @@ export async function runSlackChannelStep( if (suggestion) selected.push({ id: suggestion.id, name: suggestion.name }); } if (selected.length === 0) { - if (suggested.suggestions.length === 0) say(NO_CHANNELS_LINE); + if (suggested.suggestions.length === 0) say(NO_SLACK_CHANNELS_LINE); say(SLACK_CHANNELS_LATER_LINE); return; } diff --git a/test/cloud-connect-aws-background.test.ts b/test/cloud-connect-aws-background.test.ts index df2dc4d..63b8749 100644 --- a/test/cloud-connect-aws-background.test.ts +++ b/test/cloud-connect-aws-background.test.ts @@ -1,6 +1,13 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { startAwsStackWait } from '../src/commands/cloud/connect'; +import { + accountCheckForAwsId, + AwsStackWaits, + connectedAwsAccountsForId, + prepareAwsSubmission, + startAwsStackWait, + useAwsBackgroundPicker, +} from '../src/commands/cloud/connect'; import { startBackgroundCompletion } from '../src/commands/helpers'; import { mockConfig } from './helpers/config'; import type { CloudAccount } from '../src/generated/types'; @@ -64,14 +71,11 @@ describe('startBackgroundCompletion', () => { }); describe('startAwsStackWait', () => { - const config = mockConfig({ quiet: true, output: 'text', nonInteractive: false }); - it('flush stays silent while pending, then prints the connected transition once', async () => { let calls = 0; const output = await captureStderr(async () => { - const wait = startAwsStackWait(config, async () => (++calls >= 2 ? account() : null), { + const wait = startAwsStackWait('123456789012', async () => (++calls >= 2 ? account() : null), { intervalMs: 5, - settleTimeoutMs: 200, }); wait.flush(); assert.equal(wait.pending(), true); @@ -80,43 +84,268 @@ describe('startAwsStackWait', () => { wait.flush(); } wait.flush(); - assert.equal(await wait.settle(), 'connected'); + assert.equal(wait.stop(), 'connected'); }); const matches = output.match(/✓ AWS connected: prod \(123456789012, us-east-1\)/g); assert.equal(matches?.length, 1); }); - it('settle reports an arrival the background poll already saw', async () => { + it('stop reports an arrival the background poll already saw', async () => { const output = await captureStderr(async () => { - const wait = startAwsStackWait(config, async () => account(), { intervalMs: 5, settleTimeoutMs: 200 }); + const wait = startAwsStackWait('123456789012', async () => account(), { intervalMs: 5 }); await sleep(20); - assert.equal(await wait.settle(), 'connected'); + assert.equal(wait.stop(), 'connected'); assert.equal(wait.pending(), false); }); assert.match(output, /✓ AWS connected: prod/); }); - it('settle waits in the foreground and picks up a late arrival', async () => { - let calls = 0; + it('stop returns pending promptly without waiting for another poll', async () => { + const started = Date.now(); const output = await captureStderr(async () => { - const wait = startAwsStackWait(config, async () => (++calls >= 3 ? account() : null), { - intervalMs: 5, - settleTimeoutMs: 500, + const wait = startAwsStackWait('123456789012', async () => null, { intervalMs: 60_000 }); + assert.equal(wait.stop(), 'pending'); + assert.equal(wait.pending(), false); + }); + assert.equal(output, ''); + assert.ok(Date.now() - started < 100); + }); +}); + +describe('accountCheckForAwsId', () => { + it('only completes for the requested 12-digit account', async () => { + const existing = account({ account: '111111111111', updated: 'before' }); + const check = accountCheckForAwsId( + { existing }, + '222222222222', + async () => [ + ...account({ account: '111111111111', updated: 'after' }), + ...account({ account: '222222222222', alias: 'second' }), + ] + ); + assert.deepEqual((await check())?.map((item) => item.account), ['222222222222']); + }); + + it('ignores changes to another account while the requested one is absent', async () => { + const existing = account({ account: '111111111111', updated: 'before' }); + const check = accountCheckForAwsId( + { existing }, + '222222222222', + async () => account({ account: '111111111111', updated: 'after' }) + ); + assert.equal(await check(), null); + }); +}); + +describe('connectedAwsAccountsForId', () => { + it('short-circuits only the exact existing account and leaves a new account connectable', () => { + const existing = [ + ...account({ account: '111111111111', alias: 'existing' }), + ...account({ account: '333333333333', alias: 'other' }), + ]; + assert.deepEqual(connectedAwsAccountsForId(existing, '111111111111').map((item) => item.account), [ + '111111111111', + ]); + assert.deepEqual(connectedAwsAccountsForId(existing, '222222222222'), []); + }); +}); + +describe('prepareAwsSubmission', () => { + it('refreshes after a same-account wait completes at the pre-submit boundary', async () => { + const waits = new AwsStackWaits(); + const completed = account({ account: '111111111111', updated: 'completed' }); + waits.add(startAwsStackWait('111111111111', async () => completed, { intervalMs: 5 })); + await sleep(20); + let refreshes = 0; + + const output = await captureStderr(async () => { + const result = await prepareAwsSubmission('111111111111', false, waits, async () => { + refreshes += 1; + return completed; }); - assert.equal(await wait.settle(), 'connected'); + assert.equal(result.kind, 'connected'); + if (result.kind === 'connected') { + assert.deepEqual(result.accounts.map((item) => item.account), ['111111111111']); + } }); - assert.match(output, /✓ AWS connected: prod/); + + assert.equal(refreshes, 1); + assert.equal(output.match(/✓ AWS connected:/g)?.length, 1); + }); + + it('still allows a distinct account after another account completed', async () => { + const waits = new AwsStackWaits(); + const completed = account({ account: '111111111111', updated: 'completed' }); + waits.add(startAwsStackWait('111111111111', async () => completed, { intervalMs: 5 })); + await sleep(20); + + await captureStderr(async () => { + const result = await prepareAwsSubmission('222222222222', false, waits, async () => completed); + assert.equal(result.kind, 'submit'); + if (result.kind === 'submit') { + assert.deepEqual(result.baseline.existing.map((item) => item.account), ['111111111111']); + } + }); + }); + + it('uses the fresh snapshot as the reconnect polling baseline', async () => { + const waits = new AwsStackWaits(); + const completed = account({ account: '111111111111', id: 'aws-1', updated: 'completed' }); + const result = await prepareAwsSubmission('111111111111', true, waits, async () => completed); + assert.equal(result.kind, 'submit'); + if (result.kind !== 'submit') return; + + const unchanged = accountCheckForAwsId(result.baseline, '111111111111', async () => completed); + assert.equal(await unchanged(), null); + const changed = accountCheckForAwsId(result.baseline, '111111111111', async () => + account({ account: '111111111111', id: 'aws-1', updated: 'reconnected' }) + ); + assert.deepEqual((await changed())?.map((item) => item.updated), ['reconnected']); + }); +}); + +describe('useAwsBackgroundPicker', () => { + const interactiveText = mockConfig({ output: 'text', dryRun: false, quiet: false, nonInteractive: false }); + + it('is enabled only for the normal interactive text picker', () => { + assert.equal(useAwsBackgroundPicker(interactiveText, true), true); + assert.equal(useAwsBackgroundPicker(interactiveText, false), false); + }); + + it('does not re-enter the picker for JSON or dry-run AWS results', () => { + assert.equal(useAwsBackgroundPicker({ ...interactiveText, output: 'json' }, true), false); + assert.equal(useAwsBackgroundPicker({ ...interactiveText, dryRun: true }, true), false); + }); + + it('preserves background behavior for quiet and no-browser interactive picker runs', () => { + assert.equal(useAwsBackgroundPicker({ ...interactiveText, quiet: true }, true), true); + assert.equal(useAwsBackgroundPicker(interactiveText, true), true); + }); + + it('does not re-enter for non-interactive runs or an explicit provider', () => { + assert.equal(useAwsBackgroundPicker({ ...interactiveText, nonInteractive: true }, false), false); + assert.equal(useAwsBackgroundPicker(interactiveText, false), false); }); +}); - it('settle returns pending with check-later guidance when the stack never shows up', async () => { +describe('AwsStackWaits', () => { + it('tracks concurrent accounts and reports out-of-order completion exactly once', async () => { + let firstReady = false; + let secondReady = false; + const waits = new AwsStackWaits(); const output = await captureStderr(async () => { - const wait = startAwsStackWait(config, async () => null, { intervalMs: 5, settleTimeoutMs: 30 }); - assert.equal(await wait.settle(), 'pending'); - assert.equal(wait.pending(), false); + assert.equal( + waits.add( + startAwsStackWait( + '111111111111', + async () => (firstReady ? account({ account: '111111111111', alias: 'first' }) : null), + { intervalMs: 5 } + ) + ), + true + ); + assert.equal( + waits.add( + startAwsStackWait( + '222222222222', + async () => (secondReady ? account({ account: '222222222222', alias: 'second' }) : null), + { intervalMs: 5 } + ) + ), + true + ); + + secondReady = true; + await sleep(20); + waits.flush(); + waits.flush(); + assert.deepEqual(waits.pendingAccountIds(), ['111111111111']); + + firstReady = true; + await sleep(20); + waits.flush(); + waits.flush(); + assert.deepEqual(waits.pendingAccountIds(), []); + assert.equal(waits.hasSubmitted(), true); + assert.deepEqual(waits.finish(), []); + }); + assert.equal(output.match(/second \(222222222222/g)?.length, 1); + assert.equal(output.match(/first \(111111111111/g)?.length, 1); + }); + + it('rejects a duplicate submission only while that exact account is pending', async () => { + const waits = new AwsStackWaits(); + const first = startAwsStackWait('111111111111', async () => null, { intervalMs: 60_000 }); + const duplicate = startAwsStackWait('111111111111', async () => null, { intervalMs: 60_000 }); + assert.equal(waits.add(first), true); + assert.equal(waits.add(duplicate), false); + assert.deepEqual(waits.pendingAccountIds(), ['111111111111']); + await captureStderr(async () => { + waits.finish(); + }); + }); + + it('shows every pending account between prompts', async () => { + const waits = new AwsStackWaits(); + waits.add(startAwsStackWait('111111111111', async () => null, { intervalMs: 60_000 })); + waits.add(startAwsStackWait('222222222222', async () => null, { intervalMs: 60_000 })); + const output = await captureStderr(async () => { + waits.printPendingStatus(); + waits.finish(); + }); + assert.match(output, /^AWS pending: 111111111111, 222222222222/m); + }); + + it('does not print from background checks while a prompt may be active', async () => { + const waits = new AwsStackWaits(); + let beforeFlush = ''; + const output = await captureStderr(async () => { + waits.add(startAwsStackWait('123456789012', async () => account(), { intervalMs: 5 })); + await sleep(20); + beforeFlush = 'captured outside writer'; + waits.flush(); + }); + assert.equal(beforeFlush, 'captured outside writer'); + assert.match(output, /^✓ AWS connected:/); + }); + + it('Done stops every poller and gives per-account check and retry guidance for mixed states', async () => { + let completedCalls = 0; + let pendingCalls = 0; + const waits = new AwsStackWaits(); + const output = await captureStderr(async () => { + waits.add( + startAwsStackWait( + '111111111111', + async () => { + completedCalls += 1; + return account({ account: '111111111111' }); + }, + { intervalMs: 5 } + ) + ); + waits.add( + startAwsStackWait( + '222222222222', + async () => { + pendingCalls += 1; + return null; + }, + { intervalMs: 5 } + ) + ); + await sleep(20); + const outcomes = waits.finish(); + assert.deepEqual(outcomes, ['pending']); + const completedAfter = completedCalls; + const pendingAfter = pendingCalls; + await sleep(30); + assert.equal(completedCalls, completedAfter); + assert.equal(pendingCalls, pendingAfter); }); - assert.match(output, /AWS is still connecting/); - assert.match(output, /polylane cloud list/); - assert.match(output, /failed or rolled back/); - assert.match(output, /Exiting 7 \(pending\)/); + assert.match(output, /AWS account 222222222222 is still connecting/); + assert.match(output, /Check 222222222222: `polylane cloud list` \(look for account 222222222222\)/); + assert.match(output, /Retry 222222222222: `polylane cloud connect --provider aws --account 222222222222`/); + assert.doesNotMatch(output, /AWS account 111111111111 is still connecting/); }); }); diff --git a/test/cloud-connect-aws-regions.test.ts b/test/cloud-connect-aws-regions.test.ts index fbbe046..7370ae6 100644 --- a/test/cloud-connect-aws-regions.test.ts +++ b/test/cloud-connect-aws-regions.test.ts @@ -1,6 +1,17 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { parseAwsRegions } from '../src/commands/cloud/connect'; +import { parseAwsAccountId, parseAwsRegions } from '../src/commands/cloud/connect'; + +describe('parseAwsAccountId', () => { + it('accepts and trims a 12-digit account ID', () => { + assert.equal(parseAwsAccountId(' 123456789012 '), '123456789012'); + }); + + it('rejects IDs that are not exactly 12 digits', () => { + assert.throws(() => parseAwsAccountId('123'), /12-digit/); + assert.throws(() => parseAwsAccountId('12345678901x'), /12-digit/); + }); +}); describe('parseAwsRegions', () => { it('maps a single region to a one-element list', () => { diff --git a/test/exit-code.test.ts b/test/exit-code.test.ts index d947863..e990a67 100644 --- a/test/exit-code.test.ts +++ b/test/exit-code.test.ts @@ -63,16 +63,20 @@ describe('cloud connect exit code', () => { assert.equal(connectExitCode('connected', null), ExitCode.SUCCESS); assert.equal(connectExitCode('connected', 'connected'), ExitCode.SUCCESS); assert.equal(connectExitCode(null, 'connected'), ExitCode.SUCCESS); + assert.equal(connectExitCode(null, []), ExitCode.SUCCESS); + assert.equal(connectExitCode(null, ['connected', 'connected']), ExitCode.SUCCESS); }); it('is PENDING when the CloudFormation stack is still creating', () => { assert.equal(connectExitCode(null, 'pending'), ExitCode.PENDING); assert.equal(connectExitCode('connected', 'pending'), ExitCode.PENDING); + assert.equal(connectExitCode(null, ['connected', 'pending']), ExitCode.PENDING); }); it('is GENERAL when a browser wait timed out, even if AWS is merely pending', () => { assert.equal(connectExitCode('timeout', null), ExitCode.GENERAL); assert.equal(connectExitCode('timeout', 'pending'), ExitCode.GENERAL); assert.equal(connectExitCode('timeout', 'connected'), ExitCode.GENERAL); + assert.equal(connectExitCode('timeout', ['connected', 'pending']), ExitCode.GENERAL); }); }); diff --git a/test/integration-connect-category.test.ts b/test/integration-connect-category.test.ts index 912945c..f0c7464 100644 --- a/test/integration-connect-category.test.ts +++ b/test/integration-connect-category.test.ts @@ -45,6 +45,15 @@ describe('typeOptionsForCategory', () => { ); }); + it('explains the team benefit before Slack is selected', () => { + const slack = typeOptionsForCategory('communication')[0]; + assert.ok(slack); + assert.match(slack.hint, /start investigations/i); + assert.match(slack.hint, /incident/i); + assert.match(slack.hint, /autofix/i); + assert.match(slack.hint, /channels/i); + }); + it('covers every option with a known category', () => { for (const category of CONNECT_CATEGORIES) { assert.ok(typeOptionsForCategory(category).length > 0, `empty category: ${category}`); diff --git a/test/integration-connect-slack-channels.test.ts b/test/integration-connect-slack-channels.test.ts index 450f6c3..3fee057 100644 --- a/test/integration-connect-slack-channels.test.ts +++ b/test/integration-connect-slack-channels.test.ts @@ -4,9 +4,12 @@ import { mockConfig } from './helpers/config'; import { PICKER_DONE, PICKER_SEARCH, + NO_SLACK_CHANNELS_LINE, + SLACK_CHANNEL_PICKER_NOTE, SLACK_CHANNELS_LATER_LINE, filterChannels, formatJoinResults, + noSlackChannelMatchesLine, pickerMessage, pickerOptions, printSlackChannelsLater, @@ -55,10 +58,20 @@ describe('pickerOptions', () => { }); describe('pickerMessage', () => { - it('names the current selection', () => { - assert.match(pickerMessage([]), /Slack channels/); + it('gives concise public and private channel instructions before selection', () => { + assert.equal( + SLACK_CHANNEL_PICKER_NOTE, + 'Find and add public channels here. For private channels, invite @Polylane from the channel in Slack.' + ); + assert.equal(pickerMessage([]), 'Which public channels should Polylane join?'); assert.match(pickerMessage([{ id: 'C1', name: 'alerts' }, { id: 'C2', name: 'deploys' }]), /#alerts, #deploys/); }); + + it('keeps empty and no-match states scoped to public channels without repeating the note', () => { + assert.equal(NO_SLACK_CHANNELS_LINE, 'No public Slack channels are available here.'); + assert.equal(noSlackChannelMatchesLine('infra'), 'No public Slack channel matching "infra" is available here.'); + assert.doesNotMatch(NO_SLACK_CHANNELS_LINE, /private|invite/i); + }); }); describe('filterChannels', () => {