@@ -13,7 +13,7 @@ import { ApiErrorEntry, LaunchApiError } from '../transport/errors';
1313import { GitConnectionMissingError } from '../git/git.errors' ;
1414import { ApiSurface } from '../resources' ;
1515import { CreateRequest , ProjectCreator , UPLOAD_PROGRESS_LABEL } from './project.create' ;
16- import { UploadFailedError } from './project.errors' ;
16+ import { DuplicateProjectNameError , PROJECT_ERROR_MESSAGES , UploadFailedError } from './project.errors' ;
1717
1818jest . mock ( './project.upload' , ( ) => ( {
1919 ...jest . requireActual ( './project.upload' ) ,
@@ -88,6 +88,7 @@ interface Scenario {
8888 branches ?: unknown [ ] ;
8989 detected ?: unknown ;
9090 createFails ?: Error ;
91+ createFailures ?: Error [ ] ;
9192 repositoriesFails ?: Error ;
9293 launchAppUrl ?: string ;
9394 withoutOpenUrl ?: boolean ;
@@ -144,6 +145,12 @@ function harness(scenario: Scenario = {}) {
144145 throw scenario . createFails ;
145146 }
146147
148+ const failure = scenario . createFailures ?. shift ( ) ;
149+
150+ if ( failure ) {
151+ throw failure ;
152+ }
153+
147154 return scenario . createdProject ?? { uid : PROJECT_UID , name : 'My Site' , projectType : 'GITPROVIDER' } ;
148155 } ,
149156 signedUploadUrl : async ( params : unknown ) => {
@@ -477,7 +484,7 @@ describe('ProjectCreator on the GitHub path', () => {
477484 expect ( failure ) . toBeInstanceOf ( UsageError ) ;
478485 expect ( ( failure as Error ) . message ) . toBe (
479486 `No GitHub repository was found in ${ notAClone } . Run this command from a GitHub working copy, ` +
480- 'or pass --data-dir with the folder holding one.' ,
487+ 'or pass --data-dir with the folder holding one.' ,
481488 ) ;
482489 expect ( asked ) . toEqual ( [ ] ) ;
483490 } ) ;
@@ -492,8 +499,8 @@ describe('ProjectCreator on the GitHub path', () => {
492499 expect ( failure ) . toBeInstanceOf ( UsageError ) ;
493500 expect ( ( failure as Error ) . message ) . toBe (
494501 `The GitHub repository "my-org/my-repo" checked out in ${ dataDir } is not available to this ` +
495- 'organization\'s connected GitHub account: Something went wrong. ' +
496- 'Connect it in the Launch app, or pass --data-dir with a folder whose repository is connected.' ,
502+ 'organization\'s connected GitHub account: Something went wrong. ' +
503+ 'Connect it in the Launch app, or pass --data-dir with a folder whose repository is connected.' ,
497504 ) ;
498505 } ) ;
499506
@@ -605,8 +612,8 @@ describe('ProjectCreator on the GitHub path', () => {
605612 expect ( failure ) . toBeInstanceOf ( UsageError ) ;
606613 expect ( ( failure as Error ) . message ) . toBe (
607614 `The GitHub repository "other-org/missing-repo" checked out in ${ dataDir } is not available to this ` +
608- 'organization\'s connected GitHub account: no repository with that name was found. ' +
609- 'Connect it in the Launch app, or pass --data-dir with a folder whose repository is connected.' ,
615+ 'organization\'s connected GitHub account: no repository with that name was found. ' +
616+ 'Connect it in the Launch app, or pass --data-dir with a folder whose repository is connected.' ,
610617 ) ;
611618 } ) ;
612619
@@ -867,6 +874,62 @@ describe('ProjectCreator on the FileUpload path', () => {
867874 expect ( printed . join ( '\n' ) ) . not . toContain ( 'Uploading' ) ;
868875 } ) ;
869876
877+ it ( 'warns that --auto-deploy is not supported and creates the project without it, asking nothing' , async ( ) => {
878+ const { creator, created, printed, asked } = harness ( { isTTY : true } ) ;
879+
880+ await creator . create ( uploadRequest ( { autoDeploy : 'enable' } ) ) ;
881+
882+ expect ( printed ) . toContain ( 'warn: --auto-deploy is not supported for FileUpload projects.' ) ;
883+ expect ( asked . filter ( ( question ) => String ( question ) . includes ( 'proceed' ) ) ) . toEqual ( [ ] ) ;
884+ expect ( bodyOf ( created ) . environment ) . not . toHaveProperty ( 'autoDeployOnPush' ) ;
885+ } ) ;
886+
887+ it ( 'warns that --branch is not supported and creates the project without one' , async ( ) => {
888+ const { creator, created, printed } = harness ( ) ;
889+
890+ await creator . create ( uploadRequest ( { branch : 'main' } ) ) ;
891+
892+ expect ( printed ) . toContain ( 'warn: --branch is not supported for FileUpload projects.' ) ;
893+ expect ( bodyOf ( created ) . environment ) . toMatchObject ( { gitBranch : undefined } ) ;
894+ } ) ;
895+
896+ it ( 'warns once per unsupported flag, before zipping or uploading anything' , async ( ) => {
897+ const { creator, printed } = harness ( ) ;
898+
899+ await creator . create ( uploadRequest ( { branch : 'main' , autoDeploy : 'disable' } ) ) ;
900+
901+ expect ( printed . slice ( 0 , 3 ) ) . toEqual ( [
902+ 'warn: --branch is not supported for FileUpload projects.' ,
903+ 'warn: --auto-deploy is not supported for FileUpload projects.' ,
904+ 'Preparing zip file...' ,
905+ ] ) ;
906+ } ) ;
907+
908+ it ( 'warns and carries on without an interactive terminal too' , async ( ) => {
909+ const { creator, created, printed } = harness ( { isTTY : false } ) ;
910+
911+ await creator . create ( uploadRequest ( { autoDeploy : 'enable' } ) ) ;
912+
913+ expect ( printed ) . toContain ( 'warn: --auto-deploy is not supported for FileUpload projects.' ) ;
914+ expect ( created ) . toHaveLength ( 1 ) ;
915+ } ) ;
916+
917+ it ( 'warns about --branch when FileUpload was chosen at the type prompt' , async ( ) => {
918+ const { creator, printed } = harness ( { isTTY : true , answers : [ 'FileUpload' ] } ) ;
919+
920+ await creator . create ( uploadRequest ( { type : undefined , branch : 'main' } ) ) ;
921+
922+ expect ( printed ) . toContain ( 'warn: --branch is not supported for FileUpload projects.' ) ;
923+ } ) ;
924+
925+ it ( 'colours the warning yellow only when stdout is a terminal' , async ( ) => {
926+ const { creator, printed } = harness ( { outputIsTTY : true } ) ;
927+
928+ await creator . create ( uploadRequest ( { autoDeploy : 'enable' } ) ) ;
929+
930+ expect ( printed ) . toContain ( '\u001b[33mwarn: --auto-deploy is not supported for FileUpload projects.\u001b[39m' ) ;
931+ } ) ;
932+
870933 it . each ( [ [ true ] , [ false ] , [ undefined ] ] ) (
871934 'never spins for the zip, whose step says so in a line of its own (output terminal: %s)' ,
872935 async ( outputIsTTY ) => {
@@ -1165,10 +1228,10 @@ describe('ProjectCreator waiting on the first deployment', () => {
11651228 expect ( ( failure as DeploymentUnsuccessfulError ) . exitCode ) . toBe ( 1 ) ;
11661229 expect ( ( failure as Error ) . message ) . toBe (
11671230 'The deployment did not succeed; its last status was FAILED. ' +
1168- 'The project "My Site" (p1) and its environment "Default" were created and have not been rolled back. ' +
1169- 'Run csdx launch:deployments:create --org org1 --project p1 --env e1 to try the deployment again, ' +
1170- 'or csdx launch:logs:get --org org1 --project p1 --env e1 --deployment d1 ' +
1171- 'to see why it did not succeed.' ,
1231+ 'The project "My Site" (p1) and its environment "Default" were created and have not been rolled back. ' +
1232+ 'Run csdx launch:deployments:create --org org1 --project p1 --env e1 to try the deployment again, ' +
1233+ 'or csdx launch:logs:get --org org1 --project p1 --env e1 --deployment d1 ' +
1234+ 'to see why it did not succeed.' ,
11721235 ) ;
11731236 } ) ;
11741237
@@ -1407,7 +1470,7 @@ describe('ProjectCreator writing the project config', () => {
14071470 expect ( failure ) . toBeInstanceOf ( UsageError ) ;
14081471 expect ( ( failure as UsageError ) . message ) . toBe (
14091472 `This folder is already linked to the project other-project in ${ configPathIn ( dataDir ) } . ` +
1410- 'To create a new project, remove that file or pass --config with a different path.' ,
1473+ 'To create a new project, remove that file or pass --config with a different path.' ,
14111474 ) ;
14121475 expect ( created ) . toEqual ( [ ] ) ;
14131476 expect ( configFileIn ( dataDir ) ) . toEqual ( existing ) ;
@@ -1423,8 +1486,8 @@ describe('ProjectCreator writing the project config', () => {
14231486 expect ( created ) . toHaveLength ( 1 ) ;
14241487 expect ( printed ) . toContain (
14251488 `Could not record this project in ${ configPathIn ( dataDir ) } : ` +
1426- `The config file at '${ configPathIn ( dataDir ) } ' is not valid JSON. It was left unchanged. ` +
1427- `Pass --org ${ ORG } --project ${ PROJECT_UID } explicitly when you run Launch commands in this folder.` ,
1489+ `The config file at '${ configPathIn ( dataDir ) } ' is not valid JSON. It was left unchanged. ` +
1490+ `Pass --org ${ ORG } --project ${ PROJECT_UID } explicitly when you run Launch commands in this folder.` ,
14281491 ) ;
14291492 expect ( printed . some ( ( line ) => line . includes ( PROJECT_UID ) ) ) . toBe ( true ) ;
14301493 } ) ;
@@ -1448,3 +1511,125 @@ describe('ProjectCreator writing the project config', () => {
14481511 } ) ;
14491512
14501513} ) ;
1514+
1515+ describe ( 'ProjectCreator when the project name is already taken' , ( ) => {
1516+ const duplicate = ( ) : LaunchApiError =>
1517+ new LaunchApiError ( 409 , [ { code : 'launch.PROJECT.DUPLICATE_NAME' } ] , PROJECT_ERROR_MESSAGES ) ;
1518+
1519+ beforeEach ( ( ) => {
1520+ dataDir = mkdtempSync ( join ( tmpdir ( ) , 'launch-create-' ) ) ;
1521+ writeFileSync ( join ( dataDir , 'index.html' ) , '<h1>site</h1>' ) ;
1522+ ( uploadArchive as jest . Mock ) . mockClear ( ) ;
1523+ ( uploadArchive as jest . Mock ) . mockImplementation ( async ( ) => undefined ) ;
1524+ } ) ;
1525+
1526+ afterEach ( ( ) => {
1527+ rmSync ( dataDir , { recursive : true , force : true } ) ;
1528+ } ) ;
1529+
1530+ function terminalRequest ( ) : CreateRequest {
1531+ return uploadRequest ( { serverCmd : 'npm start' } ) ;
1532+ }
1533+
1534+ it ( 'offers a new name in a terminal and retries with it, reusing the upload rather than zipping again' , async ( ) => {
1535+ const { creator, created, printed, asked } = harness ( {
1536+ isTTY : true ,
1537+ createFailures : [ duplicate ( ) ] ,
1538+ answers : [ true , 'My Site 2' ] ,
1539+ } ) ;
1540+
1541+ await creator . create ( terminalRequest ( ) ) ;
1542+
1543+ expect ( printed ) . toEqual ( expect . arrayContaining ( [
1544+ 'error: New project creation failed!' ,
1545+ 'error: Duplicate project name identified' ,
1546+ ] ) ) ;
1547+ expect ( asked ) . toEqual ( [ 'Would you like to change the project\'s name and try again?' , 'Project name' ] ) ;
1548+ expect ( created ) . toHaveLength ( 2 ) ;
1549+ expect ( bodyOf ( created . slice ( 1 ) ) ) . toMatchObject ( { name : 'My Site 2' , fileUpload : { uploadUid : 'upload-uid' } } ) ;
1550+ expect ( uploadArchive ) . toHaveBeenCalledTimes ( 1 ) ;
1551+ } ) ;
1552+
1553+ it ( 'prints the V1 lines and exits 1 with nothing more when the user does not want to rename' , async ( ) => {
1554+ const { creator, created, printed } = harness ( { isTTY : true , createFailures : [ duplicate ( ) ] , answers : [ false ] } ) ;
1555+
1556+ const failure = await creator . create ( terminalRequest ( ) ) . catch ( ( error : Error ) => error ) ;
1557+
1558+ expect ( failure ) . toBeInstanceOf ( DuplicateProjectNameError ) ;
1559+ expect ( ( failure as DuplicateProjectNameError ) . exitCode ) . toBe ( 1 ) ;
1560+ expect ( ( failure as DuplicateProjectNameError ) . reported ) . toBe ( true ) ;
1561+ expect ( printed . slice ( - 2 ) ) . toEqual ( [
1562+ 'error: New project creation failed!' ,
1563+ 'error: Duplicate project name identified' ,
1564+ ] ) ;
1565+ expect ( created ) . toHaveLength ( 1 ) ;
1566+ } ) ;
1567+
1568+ it ( 'prints the V1 lines and says to rename without a terminal, never prompting' , async ( ) => {
1569+ const { creator, created, printed, asked } = harness ( { isTTY : false , createFailures : [ duplicate ( ) ] } ) ;
1570+
1571+ const failure = await creator . create ( uploadRequest ( ) ) . catch ( ( error : Error ) => error ) ;
1572+
1573+ expect ( failure ) . toBeInstanceOf ( DuplicateProjectNameError ) ;
1574+ expect ( ( failure as DuplicateProjectNameError ) . exitCode ) . toBe ( 1 ) ;
1575+ expect ( printed . slice ( - 3 ) ) . toEqual ( [
1576+ 'error: New project creation failed!' ,
1577+ 'error: Duplicate project name identified' ,
1578+ 'info: Change the project name and re-run the command.' ,
1579+ ] ) ;
1580+ expect ( asked ) . toEqual ( [ ] ) ;
1581+ expect ( created ) . toHaveLength ( 1 ) ;
1582+ } ) ;
1583+
1584+ it ( 'stops after three renames with the V1 retry-limit warning' , async ( ) => {
1585+ const { creator, created, printed } = harness ( {
1586+ isTTY : true ,
1587+ createFailures : [ duplicate ( ) , duplicate ( ) , duplicate ( ) , duplicate ( ) ] ,
1588+ answers : [ true , 'Two' , true , 'Three' , true , 'Four' ] ,
1589+ } ) ;
1590+
1591+ const failure = await creator . create ( terminalRequest ( ) ) . catch ( ( error : Error ) => error ) ;
1592+
1593+ expect ( failure ) . toBeInstanceOf ( DuplicateProjectNameError ) ;
1594+ expect ( printed . slice ( - 3 ) ) . toEqual ( [
1595+ 'error: New project creation failed!' ,
1596+ 'error: Duplicate project name identified' ,
1597+ 'warn: Reached max project creation retry limit' ,
1598+ ] ) ;
1599+ expect ( created ) . toHaveLength ( 4 ) ;
1600+ } ) ;
1601+
1602+ it ( 'refuses a new name over the length limit' , async ( ) => {
1603+ const { creator, created } = harness ( {
1604+ isTTY : true ,
1605+ createFailures : [ duplicate ( ) ] ,
1606+ answers : [ true , 'x' . repeat ( 201 ) ] ,
1607+ } ) ;
1608+
1609+ const failure = await creator . create ( terminalRequest ( ) ) . catch ( ( error : Error ) => error ) ;
1610+
1611+ expect ( failure ) . toBeInstanceOf ( UsageError ) ;
1612+ expect ( ( failure as Error ) . message ) . toBe ( '--name must be 200 characters or fewer; that value is 201 characters.' ) ;
1613+ expect ( created ) . toHaveLength ( 1 ) ;
1614+ } ) ;
1615+
1616+ it ( 'colours the error lines red and the rename hint green only when stdout is a terminal' , async ( ) => {
1617+ const { creator, printed } = harness ( { outputIsTTY : true , createFailures : [ duplicate ( ) ] } ) ;
1618+
1619+ await creator . create ( uploadRequest ( ) ) . catch ( ( ) => undefined ) ;
1620+
1621+ expect ( printed . slice ( - 3 ) ) . toEqual ( [
1622+ '\u001b[31merror: New project creation failed!\u001b[39m' ,
1623+ '\u001b[31merror: Duplicate project name identified\u001b[39m' ,
1624+ '\u001b[32minfo: Change the project name and re-run the command.\u001b[39m' ,
1625+ ] ) ;
1626+ } ) ;
1627+
1628+ it ( 'lets any other create failure through untouched' , async ( ) => {
1629+ const boom = new LaunchApiError ( 422 , [ { code : 'launch.PROJECT.LIMIT_REACHED' } ] , PROJECT_ERROR_MESSAGES ) ;
1630+ const { creator, asked } = harness ( { isTTY : true , createFailures : [ boom ] } ) ;
1631+
1632+ await expect ( creator . create ( terminalRequest ( ) ) ) . rejects . toBe ( boom ) ;
1633+ expect ( asked ) . toEqual ( [ ] ) ;
1634+ } ) ;
1635+ } ) ;
0 commit comments