Skip to content
Draft
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
268 changes: 268 additions & 0 deletions packages/cli/core/src/lib/atomic/createAtomicProject.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
jest.mock('node:fs');
jest.mock('../utils/process');
jest.mock('../utils/misc');
jest.mock('@coveo/cli-commons/platform/authenticatedClient');
jest.mock('@coveo/platform-client');
jest.mock('../ui/shared');

import {readFileSync, writeFileSync} from 'node:fs';
import {spawnProcess} from '../utils/process';
import {createAtomicApp} from './createAtomicProject';
import {Configuration} from '@coveo/cli-commons/config/config';
import {AuthenticatedClient} from '@coveo/cli-commons/platform/authenticatedClient';
import PlatformClient from '@coveo/platform-client';
import {getPackageVersion} from '../utils/misc';
import {promptForSearchHub} from '../ui/shared';

describe('createAtomicProject', () => {
const mockedReadFileSync = jest.mocked(readFileSync);
const mockedWriteFileSync = jest.mocked(writeFileSync);
const mockedSpawnProcess = jest.mocked(spawnProcess);
const mockedAuthenticatedClient = jest.mocked(AuthenticatedClient);
const mockedPlatformClient = jest.mocked(PlatformClient);
const mockedGetPackageVersion = jest.mocked(getPackageVersion);
const mockedPromptForSearchHub = jest.mocked(promptForSearchHub);

const mockConfig: Configuration = {
organization: 'test-org',
accessToken: 'test-token',
environment: 'dev',
region: 'us',
} as Configuration;

const originalNodeVersion = process.version;

beforeEach(() => {
jest.clearAllMocks();

mockedGetPackageVersion.mockReturnValue('1.0.0');
mockedSpawnProcess.mockResolvedValue(0);
mockedPromptForSearchHub.mockResolvedValue('default');

mockedPlatformClient.mockImplementation(
() =>
({
initialize: () => Promise.resolve(),
search: {
listSearchHubs: () =>
Promise.resolve([{id: 'default', name: 'Default'}]),
},
} as unknown as PlatformClient)
);

mockedAuthenticatedClient.mockImplementation(
() =>
({
getUsername: () => Promise.resolve('test@example.com'),
getClient: () =>
Promise.resolve(
mockedPlatformClient.getMockImplementation()!({
accessToken: 'test-token',
organizationId: 'test-org',
})
),
} as unknown as AuthenticatedClient)
);
});

afterEach(() => {
// Restore original Node version
Object.defineProperty(process, 'version', {
value: originalNodeVersion,
writable: true,
});
});

// Helper function to extract written package.json content from mock
const getWrittenPackageJson = () => {
return JSON.parse(mockedWriteFileSync.mock.calls[0][1].toString().trim());
};

describe('when Node version is < 20.19.0', () => {
beforeEach(() => {
// Mock Node version to 20.18.0
Object.defineProperty(process, 'version', {
value: 'v20.18.0',
writable: true,
});
});

it('should patch package.json scripts when project creation succeeds', async () => {
const mockPackageJson = {
name: 'test-project',
scripts: {
start: 'stencil build --dev --watch --serve',
build: 'stencil build && node deployment.esbuild.mjs',
},
};

mockedReadFileSync.mockReturnValue(JSON.stringify(mockPackageJson));

await createAtomicApp({
projectName: 'test-project',
cfg: mockConfig,
});

expect(mockedReadFileSync).toHaveBeenCalledWith(
expect.stringContaining('test-project/package.json'),
'utf8'
);

expect(mockedWriteFileSync).toHaveBeenCalledWith(
expect.stringContaining('test-project/package.json'),
expect.stringContaining('--experimental-detect-module'),
'utf8'
);

const writtenContent = getWrittenPackageJson();
expect(writtenContent.scripts.start).toContain(
'--experimental-detect-module'
);
expect(writtenContent.scripts.build).toContain(
'--experimental-detect-module'
);
});

it('should not patch package.json if project creation fails', async () => {
mockedSpawnProcess.mockResolvedValue(1);

await createAtomicApp({
projectName: 'test-project',
cfg: mockConfig,
});

expect(mockedReadFileSync).not.toHaveBeenCalled();
expect(mockedWriteFileSync).not.toHaveBeenCalled();
});

it('should handle errors gracefully when patching fails', async () => {
mockedReadFileSync.mockImplementation(() => {
throw new Error('File not found');
});

const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();

await createAtomicApp({
projectName: 'test-project',
cfg: mockConfig,
});

expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Could not modify package.json scripts')
);

consoleWarnSpy.mockRestore();
});
});

describe('when Node version is >= 20.19.0', () => {
beforeEach(() => {
// Mock Node version to 20.19.0
Object.defineProperty(process, 'version', {
value: 'v20.19.0',
writable: true,
});
});

it('should not patch package.json scripts', async () => {
await createAtomicApp({
projectName: 'test-project',
cfg: mockConfig,
});

expect(mockedReadFileSync).not.toHaveBeenCalled();
expect(mockedWriteFileSync).not.toHaveBeenCalled();
});
});

describe('when Node version is 22.x', () => {
beforeEach(() => {
// Mock Node version to 22.0.0
Object.defineProperty(process, 'version', {
value: 'v22.0.0',
writable: true,
});
});

it('should not patch package.json scripts', async () => {
await createAtomicApp({
projectName: 'test-project',
cfg: mockConfig,
});

expect(mockedReadFileSync).not.toHaveBeenCalled();
expect(mockedWriteFileSync).not.toHaveBeenCalled();
});
});

describe('package.json patching logic', () => {
beforeEach(() => {
Object.defineProperty(process, 'version', {
value: 'v20.18.0',
writable: true,
});
});

it('should correctly replace stencil command in start script', async () => {
const mockPackageJson = {
scripts: {
start: 'stencil build --dev --watch --serve',
},
};

mockedReadFileSync.mockReturnValue(JSON.stringify(mockPackageJson));

await createAtomicApp({
projectName: 'test-project',
cfg: mockConfig,
});

const writtenContent = getWrittenPackageJson();
expect(writtenContent.scripts.start).toBe(
'node --experimental-detect-module ./node_modules/.bin/stencil build --dev --watch --serve'
);
});

it('should correctly replace stencil command in build script', async () => {
const mockPackageJson = {
scripts: {
build: 'stencil build && node deployment.esbuild.mjs',
},
};

mockedReadFileSync.mockReturnValue(JSON.stringify(mockPackageJson));

await createAtomicApp({
projectName: 'test-project',
cfg: mockConfig,
});

const writtenContent = getWrittenPackageJson();
expect(writtenContent.scripts.build).toBe(
'node --experimental-detect-module ./node_modules/.bin/stencil build && node deployment.esbuild.mjs'
);
});

it('should not modify scripts that do not contain stencil', async () => {
const mockPackageJson = {
scripts: {
start: 'echo "no stencil here"',
build: 'tsc',
test: 'jest',
},
};

mockedReadFileSync.mockReturnValue(JSON.stringify(mockPackageJson));

await createAtomicApp({
projectName: 'test-project',
cfg: mockConfig,
});

const writtenContent = getWrittenPackageJson();
expect(writtenContent.scripts.start).toBe('echo "no stencil here"');
expect(writtenContent.scripts.build).toBe('tsc');
expect(writtenContent.scripts.test).toBe('jest');
});
});
});
82 changes: 78 additions & 4 deletions packages/cli/core/src/lib/atomic/createAtomicProject.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {mkdirSync} from 'node:fs';
import {resolve} from 'node:path';
import {mkdirSync, readFileSync, writeFileSync} from 'node:fs';
import {resolve, join} from 'node:path';
import {Configuration} from '@coveo/cli-commons/config/config';
import {AuthenticatedClient} from '@coveo/cli-commons/platform/authenticatedClient';
import {platformUrl} from '@coveo/cli-commons/platform/environment';
import {appendCmdIfWindows} from '@coveo/cli-commons/utils/os';
import {handleForkedProcess, spawnProcess} from '../utils/process';
import {spawnProcess} from '../utils/process';
import {
IsAuthenticated,
AuthenticationType,
Expand All @@ -22,6 +22,7 @@ import {
} from '../decorators/preconditions';
import {getPackageVersion} from '../utils/misc';
import {promptForSearchHub} from '../ui/shared';
import {lt} from 'semver';

interface CreateAppOptions {
initializerVersion?: string;
Expand Down Expand Up @@ -51,6 +52,70 @@ export const atomicAppPreconditions = [
),
];

/**
* Checks if the current Node.js version requires the --experimental-detect-module flag.
* Node versions < 20.19.0 have stricter ES module handling that requires this flag
* when using Stencil with ES module packages.
*/
function shouldAddExperimentalDetectModuleFlag(): boolean {
const nodeVersion = process.version;
// Check if Node version is less than 20.19.0
return lt(nodeVersion, '20.19.0');
}

// Command prefix to use for stencil when Node < 20.19.0
const STENCIL_COMMAND_PREFIX =
'node --experimental-detect-module ./node_modules/.bin/stencil';

/**
* Modifies the package.json scripts to add --experimental-detect-module flag
* to the build and start commands for Node versions < 20.19.0.
*/
function patchPackageJsonScripts(projectPath: string): void {
const packageJsonPath = join(projectPath, 'package.json');

try {
const packageJsonContent = readFileSync(packageJsonPath, 'utf8');
const packageJson = JSON.parse(packageJsonContent);

if (packageJson.scripts) {
// Update the start script - only if it starts with 'stencil' command
if (
packageJson.scripts.start &&
/^\s*stencil\b/.test(packageJson.scripts.start)
) {
packageJson.scripts.start = packageJson.scripts.start.replace(
/^\s*stencil/,
STENCIL_COMMAND_PREFIX
);
}

// Update the build script - only if it starts with 'stencil' command
if (
packageJson.scripts.build &&
/^\s*stencil\b/.test(packageJson.scripts.build)
) {
packageJson.scripts.build = packageJson.scripts.build.replace(
/^\s*stencil/,
STENCIL_COMMAND_PREFIX
);
}

writeFileSync(
packageJsonPath,
JSON.stringify(packageJson, null, 2) + '\n',
'utf8'
);
}
} catch (error) {
// If we can't modify the package.json, we'll just log a warning but not fail
// The user can manually add the flag if needed
console.warn(
`Warning: Could not modify package.json scripts for project '${projectPath}': ${error}. You may need to manually add '--experimental-detect-module' to your stencil commands.`
);
}
}

export async function createAtomicApp(options: CreateAppOptions) {
const authenticatedClient = new AuthenticatedClient();
const platformClient = await authenticatedClient.getClient();
Expand Down Expand Up @@ -84,7 +149,16 @@ export async function createAtomicApp(options: CreateAppOptions) {
cliArgs.push('--page-id', options.pageId);
}

return spawnProcess(appendCmdIfWindows`npx`, cliArgs);
const exitCode = await spawnProcess(appendCmdIfWindows`npx`, cliArgs);

// If the project was created successfully and Node version < 20.19.0,
// patch the package.json to add the --experimental-detect-module flag
if (exitCode === 0 && shouldAddExperimentalDetectModuleFlag()) {
const projectPath = resolve(options.projectName);
patchPackageJsonScripts(projectPath);
}

return exitCode;
}

interface CreateLibOptions {
Expand Down
Loading