11import AdmZip from 'adm-zip' ;
2+ import { randomBytes } from 'node:crypto' ;
23import { cliux } from '@contentstack/cli-utilities' ;
34import { mkdirSync , mkdtempSync , readFileSync , rmSync , symlinkSync , writeFileSync } from 'node:fs' ;
45import { tmpdir } from 'node:os' ;
@@ -13,7 +14,12 @@ import { ApiErrorEntry, LaunchApiError } from '../transport/errors';
1314import { GitConnectionMissingError } from '../git/git.errors' ;
1415import { ApiSurface } from '../resources' ;
1516import { CreateRequest , ProjectCreator , UPLOAD_PROGRESS_LABEL } from './project.create' ;
16- import { DuplicateProjectNameError , PROJECT_ERROR_MESSAGES , UploadFailedError } from './project.errors' ;
17+ import {
18+ DuplicateProjectNameError ,
19+ PROJECT_ERROR_MESSAGES ,
20+ ProjectCreateFailedError ,
21+ UploadFailedError ,
22+ } from './project.errors' ;
1723
1824jest . mock ( './project.upload' , ( ) => ( {
1925 ...jest . requireActual ( './project.upload' ) ,
@@ -23,6 +29,9 @@ jest.mock('./project.upload', () => ({
2329import * as archiveModule from './project.archive' ;
2430import { MAX_UPLOAD_BYTES , UploadOptions , uploadArchive } from './project.upload' ;
2531
32+ // Random, so it zips past the 1 KB Launch's storage providers accept as the smallest upload.
33+ const SITE_PAGE = `<h1>site</h1><!-- ${ randomBytes ( 2048 ) . toString ( 'hex' ) } -->` ;
34+
2635function fakeProgressBars ( ) {
2736 const built : Record < string , unknown > [ ] = [ ] ;
2837 const calls : string [ ] = [ ] ;
@@ -324,7 +333,7 @@ function bodyOf(created: unknown[]): Record<string, unknown> {
324333describe ( 'ProjectCreator on the GitHub path' , ( ) => {
325334 beforeEach ( ( ) => {
326335 dataDir = mkdtempSync ( join ( tmpdir ( ) , 'launch-create-' ) ) ;
327- writeFileSync ( join ( dataDir , 'index.html' ) , '<h1>site</h1>' ) ;
336+ writeFileSync ( join ( dataDir , 'index.html' ) , SITE_PAGE ) ;
328337 cloneOf ( 'my-org/my-repo' ) ;
329338 } ) ;
330339
@@ -622,7 +631,7 @@ describe('ProjectCreator on the GitHub path', () => {
622631describe ( 'ProjectCreator prompting order and refusals' , ( ) => {
623632 beforeEach ( ( ) => {
624633 dataDir = mkdtempSync ( join ( tmpdir ( ) , 'launch-create-' ) ) ;
625- writeFileSync ( join ( dataDir , 'index.html' ) , '<h1>site</h1>' ) ;
634+ writeFileSync ( join ( dataDir , 'index.html' ) , SITE_PAGE ) ;
626635 cloneOf ( 'my-org/my-repo' ) ;
627636 } ) ;
628637
@@ -837,7 +846,7 @@ describe('ProjectCreator prompting order and refusals', () => {
837846describe ( 'ProjectCreator on the FileUpload path' , ( ) => {
838847 beforeEach ( ( ) => {
839848 dataDir = mkdtempSync ( join ( tmpdir ( ) , 'launch-create-' ) ) ;
840- writeFileSync ( join ( dataDir , 'index.html' ) , '<h1>site</h1>' ) ;
849+ writeFileSync ( join ( dataDir , 'index.html' ) , SITE_PAGE ) ;
841850 ( uploadArchive as jest . Mock ) . mockClear ( ) ;
842851 ( uploadArchive as jest . Mock ) . mockImplementation ( async ( ) => undefined ) ;
843852 } ) ;
@@ -1024,6 +1033,19 @@ describe('ProjectCreator on the FileUpload path', () => {
10241033 expect ( asked ) . toEqual ( [ 'Choose a project type to proceed' ] ) ;
10251034 } ) ;
10261035
1036+ it ( 'refuses a folder that zips under 1 KB before asking for an upload url, rather than being refused by storage' , async ( ) => {
1037+ writeFileSync ( join ( dataDir , 'index.html' ) , '<h1>site</h1>' ) ;
1038+ const { creator, calls, created } = harness ( ) ;
1039+
1040+ const failure = await creator . create ( uploadRequest ( ) ) . catch ( ( error : Error ) => error ) ;
1041+
1042+ expect ( failure ) . toBeInstanceOf ( UsageError ) ;
1043+ expect ( ( failure as Error ) . message ) . toContain ( 'under the 1 KB Launch accepts for a file upload' ) ;
1044+ expect ( calls . signedUploadUrl ) . toEqual ( [ ] ) ;
1045+ expect ( uploadArchive ) . not . toHaveBeenCalled ( ) ;
1046+ expect ( created ) . toEqual ( [ ] ) ;
1047+ } ) ;
1048+
10271049 it ( 'leaves a --config file that lives inside the data dir out of the upload' , async ( ) => {
10281050 const configPath = join ( dataDir , 'launch.json' ) ;
10291051 writeFileSync ( configPath , JSON . stringify ( { project : { organizationUid : ORG } } ) ) ;
@@ -1163,7 +1185,7 @@ describe('ProjectCreator on the FileUpload path', () => {
11631185describe ( 'ProjectCreator waiting on the first deployment' , ( ) => {
11641186 beforeEach ( ( ) => {
11651187 dataDir = mkdtempSync ( join ( tmpdir ( ) , 'launch-create-' ) ) ;
1166- writeFileSync ( join ( dataDir , 'index.html' ) , '<h1>site</h1>' ) ;
1188+ writeFileSync ( join ( dataDir , 'index.html' ) , SITE_PAGE ) ;
11671189 cloneOf ( 'my-org/my-repo' ) ;
11681190 } ) ;
11691191
@@ -1393,20 +1415,27 @@ describe('ProjectCreator waiting on the first deployment', () => {
13931415 expect ( ( failure as Error ) . message ) . not . toContain ( 'undefined' ) ;
13941416 } ) ;
13951417
1396- it ( 'propagates a create failure without waiting on anything' , async ( ) => {
1397- const boom = new UsageError ( 'A project with that name already exists in this organization .' ) ;
1398- const { creator, printed } = harness ( { createFails : boom } ) ;
1418+ it ( 'reports a create failure under V1\'s header without waiting on anything' , async ( ) => {
1419+ const boom = new UsageError ( 'Project name contains characters that are not allowed .' ) ;
1420+ const { creator, printed, calls } = harness ( { createFails : boom } ) ;
13991421
1400- await expect ( creator . create ( gitRequest ( ) ) ) . rejects . toBe ( boom ) ;
1401- expect ( printed ) . toEqual ( [ ] ) ;
1422+ const failure = await creator . create ( gitRequest ( ) ) . catch ( ( error : Error ) => error ) ;
1423+
1424+ expect ( failure ) . toBeInstanceOf ( ProjectCreateFailedError ) ;
1425+ expect ( ( failure as ProjectCreateFailedError ) . exitCode ) . toBe ( 2 ) ;
1426+ expect ( printed ) . toEqual ( [
1427+ 'error: New project creation failed!' ,
1428+ 'error: Project name contains characters that are not allowed.' ,
1429+ ] ) ;
1430+ expect ( calls . environments ) . toEqual ( [ ] ) ;
14021431 } ) ;
14031432} ) ;
14041433
14051434describe ( 'ProjectCreator writing the project config' , ( ) => {
14061435 beforeEach ( ( ) => {
14071436 ( uploadArchive as jest . Mock ) . mockImplementation ( async ( ) => undefined ) ;
14081437 dataDir = mkdtempSync ( join ( tmpdir ( ) , 'launch-create-' ) ) ;
1409- writeFileSync ( join ( dataDir , 'index.html' ) , '<h1>site</h1>' ) ;
1438+ writeFileSync ( join ( dataDir , 'index.html' ) , SITE_PAGE ) ;
14101439 cloneOf ( 'my-org/my-repo' ) ;
14111440 } ) ;
14121441
@@ -1512,13 +1541,41 @@ describe('ProjectCreator writing the project config', () => {
15121541
15131542} ) ;
15141543
1544+ describe ( 'ProjectCreator name prompts' , ( ) => {
1545+ beforeEach ( ( ) => {
1546+ dataDir = mkdtempSync ( join ( tmpdir ( ) , 'launch-create-' ) ) ;
1547+ writeFileSync ( join ( dataDir , 'index.html' ) , SITE_PAGE ) ;
1548+ ( uploadArchive as jest . Mock ) . mockClear ( ) ;
1549+ ( uploadArchive as jest . Mock ) . mockImplementation ( async ( ) => undefined ) ;
1550+ } ) ;
1551+
1552+ afterEach ( ( ) => {
1553+ rmSync ( dataDir , { recursive : true , force : true } ) ;
1554+ } ) ;
1555+
1556+ it . each ( [
1557+ [ 'Project name' , 200 ] ,
1558+ [ 'Environment name' , 200 ] ,
1559+ ] ) ( 'checks a typed %s against its %i-character limit inside the prompt' , async ( message , max ) => {
1560+ const { creator, asked, askedPayloads } = harness ( { isTTY : true , answers : [ 'My Site' , 'Default' ] } ) ;
1561+
1562+ await creator . create ( uploadRequest ( { name : undefined , envName : undefined , serverCmd : 'npm start' } ) ) ;
1563+
1564+ const validate = askedPayloads [ asked . indexOf ( message ) ] . validate as ( value : string ) => true | string ;
1565+ expect ( validate ( 'x' . repeat ( max ) ) ) . toBe ( true ) ;
1566+ expect ( validate ( 'x' . repeat ( max + 1 ) ) ) . toBe (
1567+ `${ message } must be ${ max } characters or fewer; that value is ${ max + 1 } characters.` ,
1568+ ) ;
1569+ } ) ;
1570+ } ) ;
1571+
15151572describe ( 'ProjectCreator when the project name is already taken' , ( ) => {
15161573 const duplicate = ( ) : LaunchApiError =>
15171574 new LaunchApiError ( 409 , [ { code : 'launch.PROJECT.DUPLICATE_NAME' } ] , PROJECT_ERROR_MESSAGES ) ;
15181575
15191576 beforeEach ( ( ) => {
15201577 dataDir = mkdtempSync ( join ( tmpdir ( ) , 'launch-create-' ) ) ;
1521- writeFileSync ( join ( dataDir , 'index.html' ) , '<h1>site</h1>' ) ;
1578+ writeFileSync ( join ( dataDir , 'index.html' ) , SITE_PAGE ) ;
15221579 ( uploadArchive as jest . Mock ) . mockClear ( ) ;
15231580 ( uploadArchive as jest . Mock ) . mockImplementation ( async ( ) => undefined ) ;
15241581 } ) ;
@@ -1599,18 +1656,18 @@ describe('ProjectCreator when the project name is already taken', () => {
15991656 expect ( created ) . toHaveLength ( 4 ) ;
16001657 } ) ;
16011658
1602- it ( 'refuses a new name over the length limit' , async ( ) => {
1603- const { creator, created } = harness ( {
1659+ it ( 'checks a new name against the length limit inside the prompt, so the user can fix it there ' , async ( ) => {
1660+ const { creator, askedPayloads } = harness ( {
16041661 isTTY : true ,
16051662 createFailures : [ duplicate ( ) ] ,
1606- answers : [ true , 'x' . repeat ( 201 ) ] ,
1663+ answers : [ true , 'My Site 2' ] ,
16071664 } ) ;
16081665
1609- const failure = await creator . create ( terminalRequest ( ) ) . catch ( ( error : Error ) => error ) ;
1666+ await creator . create ( terminalRequest ( ) ) ;
16101667
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 ) ;
1668+ const validate = askedPayloads [ 1 ] . validate as ( value : string ) => true | string ;
1669+ expect ( validate ( 'x' . repeat ( 200 ) ) ) . toBe ( true ) ;
1670+ expect ( validate ( 'x' . repeat ( 201 ) ) ) . toBe ( 'Project name must be 200 characters or fewer; that value is 201 characters.' ) ;
16141671 } ) ;
16151672
16161673 it ( 'colours the error lines red and the rename hint green only when stdout is a terminal' , async ( ) => {
@@ -1625,11 +1682,50 @@ describe('ProjectCreator when the project name is already taken', () => {
16251682 ] ) ;
16261683 } ) ;
16271684
1628- it ( 'lets any other create failure through untouched' , async ( ) => {
1685+ it . each ( [
1686+ [ 'launch.PROJECT.LIMIT_REACHED' , 'error: Launch project limit reached!' ] ,
1687+ [ 'launch.DEPLOYMENT.INVALID_FILE_SIZE' , 'error: Please use a file over the size of 1KB and under the size of 100MB.' ] ,
1688+ [ 'launch.DEPLOYMENT.FILE_UPLOAD_FAILED' , 'error: Please use a file over the size of 1KB and under the size of 100MB.' ] ,
1689+ [ 'launch.PROJECT.CREATE_FAILED' , 'error: The Launch API could not create that project.' ] ,
1690+ ] ) ( 'reports %s under V1\'s header in V1\'s words, exiting 1 without prompting' , async ( code , cause ) => {
1691+ const boom = new LaunchApiError ( 422 , [ { code } ] , PROJECT_ERROR_MESSAGES ) ;
1692+ const { creator, asked, printed } = harness ( { isTTY : true , createFailures : [ boom ] } ) ;
1693+
1694+ const failure = await creator . create ( terminalRequest ( ) ) . catch ( ( error : Error ) => error ) ;
1695+
1696+ expect ( failure ) . toBeInstanceOf ( ProjectCreateFailedError ) ;
1697+ expect ( ( failure as ProjectCreateFailedError ) . exitCode ) . toBe ( 1 ) ;
1698+ expect ( ( failure as ProjectCreateFailedError ) . reported ) . toBe ( true ) ;
1699+ expect ( printed . slice ( - 2 ) ) . toEqual ( [ 'error: New project creation failed!' , cause ] ) ;
1700+ expect ( asked . filter ( ( question ) => String ( question ) . includes ( 'change the project' ) ) ) . toEqual ( [ ] ) ;
1701+ } ) ;
1702+
1703+ it ( 'reports a failure carrying only a message by that message' , async ( ) => {
1704+ const boom = new LaunchApiError ( 500 , [ { message : 'Something went wrong.' } ] , PROJECT_ERROR_MESSAGES ) ;
1705+ const { creator, printed } = harness ( { createFailures : [ boom ] } ) ;
1706+
1707+ await creator . create ( uploadRequest ( ) ) . catch ( ( ) => undefined ) ;
1708+
1709+ expect ( printed . slice ( - 2 ) ) . toEqual ( [ 'error: New project creation failed!' , 'error: Something went wrong.' ] ) ;
1710+ } ) ;
1711+
1712+ it ( 'colours a reported create failure red only when stdout is a terminal' , async ( ) => {
16291713 const boom = new LaunchApiError ( 422 , [ { code : 'launch.PROJECT.LIMIT_REACHED' } ] , PROJECT_ERROR_MESSAGES ) ;
1630- const { creator, asked } = harness ( { isTTY : true , createFailures : [ boom ] } ) ;
1714+ const { creator, printed } = harness ( { outputIsTTY : true , createFailures : [ boom ] } ) ;
16311715
1632- await expect ( creator . create ( terminalRequest ( ) ) ) . rejects . toBe ( boom ) ;
1633- expect ( asked ) . toEqual ( [ ] ) ;
1716+ await creator . create ( uploadRequest ( ) ) . catch ( ( ) => undefined ) ;
1717+
1718+ expect ( printed . slice ( - 2 ) ) . toEqual ( [
1719+ '\u001b[31merror: New project creation failed!\u001b[39m' ,
1720+ '\u001b[31merror: Launch project limit reached!\u001b[39m' ,
1721+ ] ) ;
1722+ } ) ;
1723+
1724+ it ( 'lets a failure that is not a Launch error through untouched, unreported' , async ( ) => {
1725+ const boom = new Error ( 'a bug, not a Launch failure' ) ;
1726+ const { creator, printed } = harness ( { createFailures : [ boom ] } ) ;
1727+
1728+ await expect ( creator . create ( uploadRequest ( ) ) ) . rejects . toBe ( boom ) ;
1729+ expect ( printed ) . not . toContain ( 'error: New project creation failed!' ) ;
16341730 } ) ;
16351731} ) ;
0 commit comments