Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .talismanrc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -596,11 +596,15 @@ FLAGS
--project=<value> 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 <org-uid> --project <name-or-uid> --env <name-or-uid>

$ csdx launch:site:open --org <org-uid> --project <name-or-uid> --env <name-or-uid> --deployment <uid>

$ url=$(csdx launch:site:open --org <org-uid> --project <name-or-uid> --env <name-or-uid>)
```

Expand Down
187 changes: 182 additions & 5 deletions src/commands/launch/site/open.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -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 <T>(payload: unknown) => {
asked.push(payload);

if (harness.answerFails) {
throw harness.answerFails;
}

return harness.answer as T;
},
};
const command = Object.create(SiteOpen.prototype) as SiteOpen;

Expand All @@ -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: {
Expand Down Expand Up @@ -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<void>): Promise<number | undefined> {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 <org-uid> --project <name-or-uid> --env <name-or-uid>)',
);
});

it('declares org, project and env as required inputs and --deployment as optional', () => {
expect(SiteOpen.inputs).toEqual({
org: { required: true },
Expand Down
31 changes: 26 additions & 5 deletions src/commands/launch/site/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -24,10 +26,14 @@ function notFoundAsUsage(error: unknown): never {
}

export default class SiteOpen extends LaunchCommand<typeof openInputs> {
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 <org-uid> --project <name-or-uid> --env <name-or-uid>',
'$ csdx launch:site:open --org <org-uid> --project <name-or-uid> --env <name-or-uid> --deployment <uid>',
'$ url=$(csdx launch:site:open --org <org-uid> --project <name-or-uid> --env <name-or-uid>)',
];

Expand All @@ -41,10 +47,15 @@ export default class SiteOpen extends LaunchCommand<typeof openInputs> {
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);
Expand All @@ -60,6 +71,16 @@ export default class SiteOpen extends LaunchCommand<typeof openInputs> {
this.services.openUrl?.(url);
}

private async chosenUrl(
url: string,
domains: readonly SiteDomain[] | undefined,
outputIsTTY: boolean,
): Promise<string> {
const choices = this.services.isTTY && outputIsTTY ? domainChoices(url, domains) : [];

return choices.length > 1 ? askForDomain(this.ux, choices) : url;
}

private async previewUrlOfDeployment(params: GetDeploymentParams): Promise<string | undefined> {
const deployment = await this.services.api.deployments.get(params).catch(notFoundAsUsage);

Expand Down
Loading
Loading