diff --git a/.talismanrc b/.talismanrc index 710ff4c..e7c6bb7 100644 --- a/.talismanrc +++ b/.talismanrc @@ -324,7 +324,7 @@ fileignoreconfig: - filename: src/commands/launch/env/update.test.ts checksum: 2da219594e7bdd589adc0eff9ff035252a75aaa51dfa20514b18fd3266d7b752 - filename: README.md - checksum: 64557ff94fa3dffc2481a8f2bce583965184be7a38ac6425457397de0b8bff96 + checksum: 6d13077c028465448536779a02a635c55069d5aeaeea6f6e4882226da8b9f75e allowed_patterns: - "password-protection" - "-p, --password" diff --git a/README.md b/README.md index 9f4b1d8..105dfb5 100644 --- a/README.md +++ b/README.md @@ -580,7 +580,7 @@ _See code: [src/commands/launch/rollback.ts](https://github.com/contentstack/lau ## `csdx launch:site:open` -Open an environment's live site in your browser, or print its URL when there is no terminal +Open an environment's live site in your browser, or print its URL when there is no terminal. On a terminal, when the environment has more than one domain, you choose which one to open; --deployment opens that deployment's preview URL without asking. ``` USAGE @@ -596,11 +596,15 @@ FLAGS --project= Project name or UID DESCRIPTION - Open an environment's live site in your browser, or print its URL when there is no terminal + Open an environment's live site in your browser, or print its URL when there is no terminal. On a terminal, when the + environment has more than one domain, you choose which one to open; --deployment opens that deployment's preview URL + without asking. EXAMPLES $ csdx launch:site:open --org --project --env + $ csdx launch:site:open --org --project --env --deployment + $ url=$(csdx launch:site:open --org --project --env ) ``` diff --git a/src/commands/launch/site/open.test.ts b/src/commands/launch/site/open.test.ts index 70a05f0..4c77811 100644 --- a/src/commands/launch/site/open.test.ts +++ b/src/commands/launch/site/open.test.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; -import { UsageError } from '../../../core/errors'; +import { CancelledError, UsageError } from '../../../core/errors'; import type { UxLike } from '../../../core/prompt'; import { DEPLOYMENT_ERROR_MESSAGES } from '../../../deployments/deployment.errors'; import { ENVIRONMENT_ERROR_MESSAGES } from '../../../environments/environment.errors'; @@ -12,9 +12,14 @@ const DEPLOYMENT_UID = randomBytes(12).toString('hex'); const DEPLOYMENT_SCOPE = { ...SCOPE, deployment: DEPLOYMENT_UID }; const PREVIEW_URL = 'https://preview-1a2b.contentstackapps.com'; const LIVE_URL = 'https://my-site.contentstackapps.com'; +const DOMAIN_URL = 'https://www.example.com'; +const OTHER_DOMAIN_URL = 'https://shop.example.com'; interface Harness { outputIsTTY?: boolean; + isTTY?: boolean; + answer?: unknown; + answerFails?: Error; withoutOpener?: boolean; environment?: unknown; environmentFails?: Error; @@ -31,11 +36,20 @@ function commandUnderTest(harness: Harness = {}) { const calls: string[] = []; const got: unknown[] = []; const opened: string[] = []; + const asked: unknown[] = []; const ux: UxLike = { print: (message: string) => { stdout.push(message); }, - inquire: async () => undefined as never, + inquire: async (payload: unknown) => { + asked.push(payload); + + if (harness.answerFails) { + throw harness.answerFails; + } + + return harness.answer as T; + }, }; const command = Object.create(SiteOpen.prototype) as SiteOpen; @@ -44,7 +58,7 @@ function commandUnderTest(harness: Harness = {}) { resolved: { org: 'org1', project: 'proj1', env: 'env1', deployment: harness.deployment }, services: { ux, - isTTY: false, + isTTY: harness.isTTY ?? true, outputIsTTY: harness.outputIsTTY ?? true, openUrl: harness.withoutOpener ? undefined : (url: string) => opened.push(url), api: { @@ -91,7 +105,7 @@ function commandUnderTest(harness: Harness = {}) { return true; }); - return { command, stdout, stderr, calls, got, opened }; + return { command, stdout, stderr, calls, got, opened, asked }; } async function exitCodeOf(run: Promise): Promise { @@ -242,7 +256,11 @@ describe('launch:site:open', () => { expect(opened).toEqual([]); }); - it.each([['absent', undefined], ['empty', ''], ['whitespace only', ' ']])( + it.each([ + ['absent', undefined], + ['empty', ''], + ['whitespace only', ' '], + ])( 'says the website url was not found in yellow on stdout and exits 2 when the named deployment has a %s preview url', async (_label, previewUrl) => { const { command, stdout, stderr, calls, got, opened } = commandUnderTest({ @@ -318,6 +336,165 @@ describe('launch:site:open', () => { expect(calls).toEqual(['environments.get']); }); + it('asks which domain to open, as a single-select list with the default preselected, and opens only the one picked', async () => { + const { command, stdout, calls, opened, asked } = commandUnderTest({ + environment: { uid: 'env1', domains: [{ url: DOMAIN_URL }, { url: 'shop.example.com' }] }, + answer: OTHER_DOMAIN_URL, + }); + + await command.run(); + + expect(asked).toEqual([ + { + type: 'list', + name: 'value', + message: 'Which domain do you want to open?', + default: LIVE_URL, + choices: [ + { name: 'my-site.contentstackapps.com (default)', value: LIVE_URL }, + { name: 'www.example.com', value: DOMAIN_URL }, + { name: 'shop.example.com', value: OTHER_DOMAIN_URL }, + ], + loop: false, + pageSize: 3, + }, + ]); + expect(stdout).toEqual([`\u001b[32m✔ Opening ${OTHER_DOMAIN_URL} in your browser...\u001b[39m`]); + expect(opened).toEqual([OTHER_DOMAIN_URL]); + expect(calls).toEqual(['environments.get', 'deployments.latest']); + }); + + it('opens only the default url when it is the one picked', async () => { + const { command, stdout, opened, asked } = commandUnderTest({ + environment: { uid: 'env1', domains: [{ url: DOMAIN_URL }] }, + answer: LIVE_URL, + }); + + await command.run(); + + expect(asked).toHaveLength(1); + expect(stdout).toEqual([`\u001b[32m✔ Opening ${LIVE_URL} in your browser...\u001b[39m`]); + expect(opened).toEqual([LIVE_URL]); + }); + + it.each([ + ['no domains', { uid: 'env1', domains: [] }, undefined, LIVE_URL], + [ + 'a domain that is the default url again', + { uid: 'env1', domains: [{ url: 'MY-SITE.contentstackapps.com/' }] }, + undefined, + LIVE_URL, + ], + [ + 'no deployment url and one domain', + { uid: 'env1', domains: [{ url: 'www.example.com' }] }, + { uid: 'd1' }, + DOMAIN_URL, + ], + ])('asks nothing and opens the one url there is with %s', async (_label, environment, latest, expected) => { + const { command, stdout, opened, asked } = commandUnderTest({ + environment, + ...(latest === undefined ? {} : { latest }), + }); + + await command.run(); + + expect(asked).toEqual([]); + expect(stdout).toEqual([`\u001b[32m✔ Opening ${expected} in your browser...\u001b[39m`]); + expect(opened).toEqual([expected]); + }); + + it.each([ + ['rejects with a CancelledError', { answerFails: new CancelledError() }], + ['answers nothing', {}], + ])('exits 3 and prints and opens nothing when the prompt %s', async (_label, harness) => { + const { command, stdout, stderr, opened } = commandUnderTest({ + environment: { uid: 'env1', domains: [{ url: DOMAIN_URL }] }, + ...harness, + }); + + const error = (await command.run().catch((thrown: unknown) => thrown)) as CancelledError; + + expect(error).toBeInstanceOf(CancelledError); + expect(error.exitCode).toBe(3); + expect(stdout).toEqual([]); + expect(stderr).toEqual([]); + expect(opened).toEqual([]); + }); + + it.each([ + ['stdout and stdin are not terminals', { outputIsTTY: false, isTTY: false }], + ['only stdin is a terminal', { outputIsTTY: false, isTTY: true }], + ])('prints only the default url without asking when %s', async (_label, harness) => { + const { command, stdout, stderr, opened, asked } = commandUnderTest({ + ...harness, + environment: { uid: 'env1', domains: [{ url: DOMAIN_URL }, { url: OTHER_DOMAIN_URL }] }, + }); + + await command.run(); + + expect(asked).toEqual([]); + expect(stdout).toEqual([LIVE_URL]); + expect(stderr).toEqual([]); + expect(opened).toEqual([]); + }); + + it('opens the default url without asking when stdout is a terminal but stdin is not', async () => { + const { command, stdout, opened, asked } = commandUnderTest({ + isTTY: false, + environment: { uid: 'env1', domains: [{ url: DOMAIN_URL }] }, + }); + + await command.run(); + + expect(asked).toEqual([]); + expect(stdout).toEqual([`\u001b[32m✔ Opening ${LIVE_URL} in your browser...\u001b[39m`]); + expect(opened).toEqual([LIVE_URL]); + }); + + it('asks nothing for a named deployment even when the environment has domains', async () => { + const { command, calls, opened, asked } = commandUnderTest({ + deployment: DEPLOYMENT_UID, + fetched: { uid: DEPLOYMENT_UID, previewUrl: PREVIEW_URL }, + environment: { uid: 'env1', domains: [{ url: DOMAIN_URL }, { url: OTHER_DOMAIN_URL }] }, + }); + + await command.run(); + + expect(asked).toEqual([]); + expect(opened).toEqual([PREVIEW_URL]); + expect(calls).toEqual(['environments.get', 'deployments.get']); + }); + + it.each([[[]], [[{ url: DOMAIN_URL }, { url: OTHER_DOMAIN_URL }]]])( + 'exits 2 with the website url not found line and asks nothing when there is no deployment and domains %j', + async (domains) => { + const { command, stdout, opened, asked } = commandUnderTest({ + environment: { uid: 'env1', domains }, + latest: undefined, + }); + + const code = await exitCodeOf(command.run()); + + expect(code).toBe(2); + expect(stdout).toEqual(['\u001b[33mWebsite URL not found.\u001b[39m']); + expect(asked).toEqual([]); + expect(opened).toEqual([]); + }, + ); + + it('declares no --domain flag', () => { + expect(Object.keys(SiteOpen.flags)).not.toContain('domain'); + }); + + it('describes the domain prompt in its help', () => { + expect(SiteOpen.description).toContain('choose which one to open'); + expect(SiteOpen.description).toContain('--deployment'); + expect(SiteOpen.examples).toContain( + '$ url=$(csdx launch:site:open --org --project --env )', + ); + }); + it('declares org, project and env as required inputs and --deployment as optional', () => { expect(SiteOpen.inputs).toEqual({ org: { required: true }, diff --git a/src/commands/launch/site/open.ts b/src/commands/launch/site/open.ts index 8fa3e26..510e795 100644 --- a/src/commands/launch/site/open.ts +++ b/src/commands/launch/site/open.ts @@ -4,7 +4,9 @@ import { writeToStderr } from '../../../core/failure'; import { flagsFor, inputs } from '../../../core/inputs'; import { LaunchCommand } from '../../../core/launch-command'; import type { GetDeploymentParams } from '../../../deployments/deployments.api'; +import { domainChoices, askForDomain } from '../../../site/site.prompt'; import { siteOpeningLine, siteUrlNotFoundLine } from '../../../site/site.presenter'; +import type { SiteDomain } from '../../../site/site.url'; import { previewUrlOf, siteUrlOf } from '../../../site/site.url'; import { LaunchApiError } from '../../../transport/errors'; @@ -24,10 +26,14 @@ function notFoundAsUsage(error: unknown): never { } export default class SiteOpen extends LaunchCommand { - static description = "Open an environment's live site in your browser, or print its URL when there is no terminal"; + static description = + "Open an environment's live site in your browser, or print its URL when there is no terminal. " + + 'On a terminal, when the environment has more than one domain, you choose which one to open; ' + + "--deployment opens that deployment's preview URL without asking."; static examples = [ '$ csdx launch:site:open --org --project --env ', + '$ csdx launch:site:open --org --project --env --deployment ', '$ url=$(csdx launch:site:open --org --project --env )', ]; @@ -41,10 +47,15 @@ export default class SiteOpen extends LaunchCommand { const outputIsTTY = this.services.outputIsTTY === true; const environment = await this.services.api.environments.get(scope).catch(notFoundAsUsage); - const url = - deploymentUid === undefined - ? siteUrlOf(await this.services.api.deployments.latest(scope), environment.domains) - : await this.previewUrlOfDeployment({ ...scope, deployment: deploymentUid }); + let url: string | undefined; + + if (deploymentUid === undefined) { + url = siteUrlOf(await this.services.api.deployments.latest(scope), environment.domains); + + url = url === undefined ? url : await this.chosenUrl(url, environment.domains, outputIsTTY); + } else { + url = await this.previewUrlOfDeployment({ ...scope, deployment: deploymentUid }); + } if (url === undefined) { this.reportMissingUrl(outputIsTTY); @@ -60,6 +71,16 @@ export default class SiteOpen extends LaunchCommand { this.services.openUrl?.(url); } + private async chosenUrl( + url: string, + domains: readonly SiteDomain[] | undefined, + outputIsTTY: boolean, + ): Promise { + const choices = this.services.isTTY && outputIsTTY ? domainChoices(url, domains) : []; + + return choices.length > 1 ? askForDomain(this.ux, choices) : url; + } + private async previewUrlOfDeployment(params: GetDeploymentParams): Promise { const deployment = await this.services.api.deployments.get(params).catch(notFoundAsUsage); diff --git a/src/site/site.prompt.test.ts b/src/site/site.prompt.test.ts new file mode 100644 index 00000000..b7c93b8 --- /dev/null +++ b/src/site/site.prompt.test.ts @@ -0,0 +1,121 @@ +import type { UxLike } from '../core/prompt'; +import { CancelledError } from '../core/errors'; +import { domainChoices, askForDomain } from './site.prompt'; + +const DEFAULT_URL = 'https://my-site.contentstackapps.com'; + +describe('domainChoices', () => { + it('lists the default url first, labelled (default), then each domain by hostname with its url as the value', () => { + const choices = domainChoices(DEFAULT_URL, [{ url: 'https://www.example.com' }, { url: 'shop.example.com' }]); + + expect(choices).toEqual([ + { name: 'my-site.contentstackapps.com (default)', value: DEFAULT_URL }, + { name: 'www.example.com', value: 'https://www.example.com' }, + { name: 'shop.example.com', value: 'https://shop.example.com' }, + ]); + }); + + it('collapses domains that differ only by scheme, case or trailing slash, keeping the default as the first occurrence', () => { + const choices = domainChoices(DEFAULT_URL, [ + { url: 'http://MY-SITE.contentstackapps.com/' }, + { url: 'https://www.example.com' }, + { url: 'WWW.Example.com//' }, + { url: 'http://www.example.com' }, + ]); + + expect(choices).toEqual([ + { name: 'my-site.contentstackapps.com (default)', value: DEFAULT_URL }, + { name: 'www.example.com', value: 'https://www.example.com' }, + ]); + }); + + it.each([[undefined], [[]]])('returns only the default url for domains %p', (domains) => { + expect(domainChoices(DEFAULT_URL, domains)).toEqual([ + { name: 'my-site.contentstackapps.com (default)', value: DEFAULT_URL }, + ]); + }); + + it('skips domains with no, null, empty or blank url', () => { + const domains = [ + {}, + { url: undefined }, + { url: null }, + { url: '' }, + { url: ' ' }, + { url: 'https://' }, + { url: 'a.example.com' }, + ]; + + const choices = domainChoices(DEFAULT_URL, domains as { url?: string }[]); + + expect(choices).toEqual([ + { name: 'my-site.contentstackapps.com (default)', value: DEFAULT_URL }, + { name: 'a.example.com', value: 'https://a.example.com' }, + ]); + expect(choices.some((choice) => choice.name === '' || choice.value === 'https://')).toBe(false); + }); + + it('keeps http:// and https:// as given, adds https:// to a bare host and names every choice by its bare host', () => { + const choices = domainChoices(DEFAULT_URL, [ + { url: 'http://plain.example.test' }, + { url: 'https://secure.example.test/' }, + { url: 'bare.example.test' }, + ]); + + expect(choices.slice(1)).toEqual([ + { name: 'plain.example.test', value: 'http://plain.example.test' }, + { name: 'secure.example.test', value: 'https://secure.example.test' }, + { name: 'bare.example.test', value: 'https://bare.example.test' }, + ]); + }); + + it('does not filter a domain by any other field', () => { + const choices = domainChoices(DEFAULT_URL, [ + { url: 'a.example.com', createdAt: '2025-01-01T00:00:00.000Z' } as { url: string }, + { url: 'b.example.com' }, + ]); + + expect(choices.map((choice) => choice.name)).toEqual([ + 'my-site.contentstackapps.com (default)', + 'a.example.com', + 'b.example.com', + ]); + }); +}); + +describe('askForDomain', () => { + it('asks a single-select list with the default preselected and returns the picked value', async () => { + const payloads: unknown[] = []; + const ux: UxLike = { + print: () => undefined, + inquire: async (payload: unknown) => { + payloads.push(payload); + return 'https://www.example.com' as T; + }, + }; + const choices = domainChoices(DEFAULT_URL, [{ url: 'www.example.com' }]); + + const picked = await askForDomain(ux, choices); + + expect(picked).toBe('https://www.example.com'); + expect(payloads).toEqual([ + { + type: 'list', + name: 'value', + message: 'Which domain do you want to open?', + choices, + default: DEFAULT_URL, + loop: false, + pageSize: 2, + }, + ]); + }); + + it('raises a CancelledError when nothing is chosen', async () => { + const ux: UxLike = { print: () => undefined, inquire: async () => undefined as never }; + + await expect(askForDomain(ux, domainChoices(DEFAULT_URL, [{ url: 'a.example.com' }]))).rejects.toBeInstanceOf( + CancelledError, + ); + }); +}); diff --git a/src/site/site.prompt.ts b/src/site/site.prompt.ts new file mode 100644 index 00000000..6b31471 --- /dev/null +++ b/src/site/site.prompt.ts @@ -0,0 +1,39 @@ +import type { Choice, UxLike } from '../core/prompt'; +import { askOption } from '../core/prompt'; +import type { SiteDomain } from './site.url'; + +export const DOMAIN_QUESTION = 'Which domain do you want to open?'; + +const SCHEME = /^[a-z][a-z\d+.-]*:\/\//i; +const TRAILING_SLASHES = /\/+$/; + +function bareHost(url: string): string { + return url.trim().replace(SCHEME, '').replace(TRAILING_SLASHES, ''); +} + +function withScheme(url: string): string { + const trimmed = url.trim().replace(TRAILING_SLASHES, ''); + + return SCHEME.test(trimmed) ? trimmed : `https://${trimmed}`; +} + +export function domainChoices(defaultUrl: string, domains: readonly SiteDomain[] = []): Choice[] { + const defaultHost = bareHost(defaultUrl); + const seen = new Set([defaultHost.toLowerCase()]); + const choices: Choice[] = [{ name: `${defaultHost} (default)`, value: defaultUrl }]; + + for (const domain of domains) { + const host = bareHost(domain.url ?? ''); + + if (host !== '' && !seen.has(host.toLowerCase())) { + seen.add(host.toLowerCase()); + choices.push({ name: host, value: withScheme(domain.url as string) }); + } + } + + return choices; +} + +export async function askForDomain(ux: UxLike, choices: Choice[]): Promise { + return askOption(ux, DOMAIN_QUESTION, choices, choices[0].value); +} diff --git a/test/integration/site-open.test.ts b/test/integration/site-open.test.ts index 52952e7..00aa856 100644 --- a/test/integration/site-open.test.ts +++ b/test/integration/site-open.test.ts @@ -10,7 +10,7 @@ import open from 'open'; import { loadCliConfig, routeConsoleLogToStdout } from '../support/oclif'; import { projectOwnership } from '../support/project-ownership'; -import { stdinReportingTTY, stdoutReportingTTY } from '../support/terminal'; +import { answerPrompts, CTRL_C, stdinReportingTTY, stdoutReportingTTY } from '../support/terminal'; import getFixture from '../fixtures/environment-get.json'; import listFixture from '../fixtures/environments-list.json'; @@ -33,6 +33,7 @@ const DEPLOYMENT_PATH = `${DEPLOYMENTS_PATH}/${DEPLOYMENT_UID}`; const PREVIEW_URL = 'https://preview-1a2b.contentstackapps.com'; const LIVE_URL = 'https://sample-project.contentstackapps.com'; const DOMAIN_URL = 'https://production-sample-project.contentstackapps.com'; +const QUESTION = 'Which domain do you want to open?'; const NOT_FOUND_LINE = 'Website URL not found.'; const SCANNED = { @@ -91,6 +92,14 @@ function runOpen(args: string[]) { return runCommand(args, config, { stripAnsi: false }); } +function chooseDefault(): void { + answerPrompts({ [QUESTION]: LIVE_URL }); +} + +function forbidPrompts() { + return answerPrompts({}); +} + function withTerminal(stdout: boolean, stdin = stdout): void { restoreStreams.push(stdoutReportingTTY(stdout), stdinReportingTTY(stdin)); } @@ -129,8 +138,11 @@ describe('integration: launch:site:open on the wire', () => { it('opens the latest deployment url of an environment named on the command line and says so on a terminal', async () => { withTerminal(true); + chooseDefault(); const scan = nock(LAUNCH_HUB_URL).get(ENVIRONMENTS_PATH).query({ limit: '100', skip: '0' }).reply(200, SCANNED); - const { ownership, environment, deployments } = reads([{ uid: 'd1', deploymentUrl: 'sample-project.contentstackapps.com' }]); + const { ownership, environment, deployments } = reads([ + { uid: 'd1', deploymentUrl: 'sample-project.contentstackapps.com' }, + ]); const { error, stdout } = await runOpen(openArgs('--env', 'production')); @@ -138,7 +150,12 @@ describe('integration: launch:site:open on the wire', () => { expect(stripVTControlCharacters(stdout)).toContain(`✔ Opening ${LIVE_URL} in your browser...`); expect(open).toHaveBeenCalledTimes(1); expect(open).toHaveBeenCalledWith(LIVE_URL); - expect([ownership, scan, environment, deployments].map((scope) => scope.isDone())).toEqual([true, true, true, true]); + expect([ownership, scan, environment, deployments].map((scope) => scope.isDone())).toEqual([ + true, + true, + true, + true, + ]); expect(onWire).toEqual([]); }); @@ -179,6 +196,7 @@ describe('integration: launch:site:open on the wire', () => { it('still prints the opening line and exits 0 when the browser cannot be launched', async () => { withTerminal(true); + chooseDefault(); (open as unknown as jest.Mock).mockImplementation(() => { throw new Error('spawn xdg-open ENOENT'); }); @@ -219,6 +237,7 @@ describe('integration: launch:site:open on the wire', () => { it('sends only GET requests, the environment before the deployments, and nothing unmatched', async () => { withTerminal(true); + chooseDefault(); const { ownership, environment, deployments } = reads([{ uid: 'd1', deploymentUrl: LIVE_URL }]); const { error } = await runOpen(openArgs('--env', ENV_UID)); @@ -263,6 +282,139 @@ describe('integration: launch:site:open on the wire', () => { expect(onWire).toEqual([]); }); + it('asks which domain to open as a single-select list with the default first and preselected, and opens only the one picked', async () => { + withTerminal(true); + const prompts = answerPrompts({ [QUESTION]: DOMAIN_URL }); + reads([{ uid: 'd1', deploymentUrl: 'sample-project.contentstackapps.com' }]); + + const { error, stdout } = await runOpen(openArgs('--env', ENV_UID)); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual([QUESTION]); + expect(prompts.payloads).toHaveLength(1); + expect(prompts.payloads[0]).toMatchObject({ + type: 'list', + name: 'value', + default: LIVE_URL, + choices: [ + { name: 'sample-project.contentstackapps.com (default)', value: LIVE_URL }, + { name: 'production-sample-project.contentstackapps.com', value: DOMAIN_URL }, + ], + }); + expect(open).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledWith(DOMAIN_URL); + expect(stripVTControlCharacters(stdout)).toContain(`✔ Opening ${DOMAIN_URL} in your browser...`); + expect(onWire).toEqual([]); + }); + + it('exits 3 and opens nothing when Ctrl-C is pressed at the domain prompt, having sent only the two reads', async () => { + withTerminal(true); + answerPrompts({ [QUESTION]: CTRL_C }); + reads([{ uid: 'd1', deploymentUrl: LIVE_URL }]); + + const { error, stdout } = await runOpen(openArgs('--env', ENV_UID)); + + expect(error?.oclif?.exit).toBe(3); + expect(open).not.toHaveBeenCalled(); + expect(stripVTControlCharacters(stdout)).not.toContain('✔ Opening'); + expect(sent).toEqual([`GET ${ENV_PATH}`, `GET ${DEPLOYMENTS_PATH}`]); + expect(onWire).toEqual([]); + }); + + it('has no --domain flag: it is refused as unknown with exit 2 before any request or browser', async () => { + withTerminal(true); + forbidPrompts(); + reads([{ uid: 'd1', deploymentUrl: LIVE_URL }]); + + const { error } = await runOpen(openArgs('--env', ENV_UID, '--domain', 'www.example.com')); + + expect(error?.oclif?.exit).toBe(2); + expect(error?.message).toContain('Nonexistent flag'); + expect(sent).toEqual([]); + expect(onWire).toEqual([]); + expect(open).not.toHaveBeenCalled(); + }); + + it.each([ + ['stdin is a terminal and stdout is piped', false, true], + ['neither stream is a terminal', false, false], + ])('prints only the default url, asks nothing and opens nothing when %s', async (_label, stdout, stdin) => { + withTerminal(stdout, stdin); + const prompts = forbidPrompts(); + reads([{ uid: 'd1', deploymentUrl: LIVE_URL }]); + + const { error, stdout: printed } = await runOpen(openArgs('--env', ENV_UID)); + + expect(error).toBeUndefined(); + expect(printed).toBe(`${LIVE_URL}\n`); + expect(prompts.messages).toEqual([]); + expect(open).not.toHaveBeenCalled(); + }); + + it('opens the default url without asking when stdout is a terminal but stdin is not', async () => { + withTerminal(true, false); + const prompts = forbidPrompts(); + reads([{ uid: 'd1', deploymentUrl: LIVE_URL }]); + + const { error, stdout } = await runOpen(openArgs('--env', ENV_UID)); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual([]); + expect(open).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledWith(LIVE_URL); + expect(stripVTControlCharacters(stdout)).toContain(`✔ Opening ${LIVE_URL} in your browser...`); + }); + + it('asks nothing for a named deployment on a terminal and opens its preview url', async () => { + withTerminal(true); + const prompts = forbidPrompts(); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + nock(LAUNCH_HUB_URL) + .get(ENV_PATH) + .reply(200, () => { + sent.push(`GET ${ENV_PATH}`); + return getFixture; + }); + nock(LAUNCH_HUB_URL) + .get(DEPLOYMENT_PATH) + .reply(200, () => { + sent.push(`GET ${DEPLOYMENT_PATH}`); + return { deployment: { uid: DEPLOYMENT_UID, deploymentUrl: LIVE_URL, previewUrl: PREVIEW_URL } }; + }); + + const { error } = await runOpen(openArgs('--env', ENV_UID, '--deployment', DEPLOYMENT_UID)); + + expect(error).toBeUndefined(); + expect(prompts.messages).toEqual([]); + expect(open).toHaveBeenCalledWith(PREVIEW_URL); + expect(sent).toEqual([`GET ${ENV_PATH}`, `GET ${DEPLOYMENT_PATH}`]); + }); + + it('exits 2 with the yellow not-found line and asks nothing when there is no deployment but two domains', async () => { + withTerminal(true); + const prompts = forbidPrompts(); + projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); + nock(LAUNCH_HUB_URL) + .get(ENV_PATH) + .reply(200, { + environment: { + ...getFixture.environment, + domains: [{ url: DOMAIN_URL }, { url: 'https://www.example.com' }], + }, + }); + nock(LAUNCH_HUB_URL) + .get(DEPLOYMENTS_PATH) + .query({ limit: '1', skip: '0' }) + .reply(200, { pagination: { count: 0, limit: 1, skip: 0 }, deployments: [] }); + + const { error, stdout } = await runOpen(openArgs('--env', ENV_UID)); + + expect(error?.oclif?.exit).toBe(2); + expect(stdout).toContain(`\u001b[33m${NOT_FOUND_LINE}\u001b[39m`); + expect(prompts.messages).toEqual([]); + expect(open).not.toHaveBeenCalled(); + }); + it('refuses a deployment that is not a 24-hex uid before any request reaches the wire', async () => { withTerminal(true); @@ -319,7 +471,9 @@ describe('integration: launch:site:open on the wire', () => { it('exits 2 with the not-found wording for an environment uid of another project, reading no deployment', async () => { withTerminal(true); projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); - const lookup = nock(LAUNCH_HUB_URL).get(`${ENVIRONMENTS_PATH}/${OTHER_PROJECT_ENV_UID}`).reply(404, notFoundFixture); + const lookup = nock(LAUNCH_HUB_URL) + .get(`${ENVIRONMENTS_PATH}/${OTHER_PROJECT_ENV_UID}`) + .reply(404, notFoundFixture); const deployments = nock(LAUNCH_HUB_URL) .get(`${ENVIRONMENTS_PATH}/${OTHER_PROJECT_ENV_UID}/deployments`) .query(true) @@ -338,8 +492,13 @@ describe('integration: launch:site:open on the wire', () => { withTerminal(true); projectOwnership(LAUNCH_HUB_URL, ORG_UID, PROJECT_UID); nock(LAUNCH_HUB_URL).get(ENVIRONMENTS_PATH).query({ limit: '100', skip: '0' }).reply(200, SCANNED); - const environment = nock(LAUNCH_HUB_URL).get(/\/environments\/[0-9a-f]{24}$/).reply(200, getFixture); - const deployments = nock(LAUNCH_HUB_URL).get(/\/deployments$/).query(true).reply(200, { deployments: [] }); + const environment = nock(LAUNCH_HUB_URL) + .get(/\/environments\/[0-9a-f]{24}$/) + .reply(200, getFixture); + const deployments = nock(LAUNCH_HUB_URL) + .get(/\/deployments$/) + .query(true) + .reply(200, { deployments: [] }); const { error } = await runOpen(openArgs('--env', 'other-project-env'));