Skip to content
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/utils/init/config-manual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ export default async function configManual({
const deployKey = await createDeployKey({ api })
await addDeployKey(deployKey)

const repoPath = await getRepoPath({ repoData })
const repoPath = repoData.repo ?? (await getRepoPath({ repoData }))
const repo = {
provider: 'manual',
provider: repoData.provider ?? 'manual',
repo_path: repoPath,
repo_branch: repoData.branch,
allowed_branches: [repoData.branch],
Expand Down
120 changes: 120 additions & 0 deletions tests/unit/utils/get-repo-data.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, expect, it, vi } from 'vitest'
import type { RepoData } from '../../../src/utils/get-repo-data.js'

vi.mock('../../../src/utils/command-helpers.js', () => ({
log: vi.fn(),
}))

describe('getRepoData', () => {
describe('RepoData structure for different Git providers', () => {
it('should construct correct httpsUrl for GitHub SSH URLs', () => {
const mockRepoData: RepoData = {
name: 'test',
owner: 'ownername',
repo: 'ownername/test',
url: 'git@github.com:ownername/test.git',
branch: 'main',
provider: 'github',
httpsUrl: 'https://github.com/ownername/test',
}

expect(mockRepoData.httpsUrl).toBe('https://github.com/ownername/test')
expect(mockRepoData.provider).toBe('github')
expect(mockRepoData.repo).toBe('ownername/test')
})

it('should construct correct httpsUrl for GitLab SSH URLs', () => {
const mockRepoData: RepoData = {
name: 'test',
owner: 'ownername',
repo: 'ownername/test',
url: 'git@gitlab.com:ownername/test.git',
branch: 'main',
provider: 'gitlab',
httpsUrl: 'https://gitlab.com/ownername/test',
}

expect(mockRepoData.httpsUrl).toBe('https://gitlab.com/ownername/test')
expect(mockRepoData.provider).toBe('gitlab')
expect(mockRepoData.repo).toBe('ownername/test')
})

it('should construct correct httpsUrl for GitHub HTTPS URLs', () => {
const mockRepoData: RepoData = {
name: 'test',
owner: 'ownername',
repo: 'ownername/test',
url: 'https://github.com/ownername/test.git',
branch: 'main',
provider: 'github',
httpsUrl: 'https://github.com/ownername/test',
}

expect(mockRepoData.httpsUrl).toBe('https://github.com/ownername/test')
expect(mockRepoData.provider).toBe('github')
expect(mockRepoData.repo).toBe('ownername/test')
})

it('should construct correct httpsUrl for GitLab HTTPS URLs', () => {
const mockRepoData: RepoData = {
name: 'test',
owner: 'ownername',
repo: 'ownername/test',
url: 'https://gitlab.com/ownername/test.git',
branch: 'main',
provider: 'gitlab',
httpsUrl: 'https://gitlab.com/ownername/test',
}

expect(mockRepoData.httpsUrl).toBe('https://gitlab.com/ownername/test')
expect(mockRepoData.provider).toBe('gitlab')
expect(mockRepoData.repo).toBe('ownername/test')
})

it('should use host as provider for unknown Git hosts', () => {
const mockRepoData: RepoData = {
name: 'test',
owner: 'user',
repo: 'user/test',
url: 'git@custom-git.example.com:user/test.git',
branch: 'main',
provider: 'custom-git.example.com',
httpsUrl: 'https://custom-git.example.com/user/test',
}

expect(mockRepoData.httpsUrl).toBe('https://custom-git.example.com/user/test')
expect(mockRepoData.provider).toBe('custom-git.example.com')
expect(mockRepoData.repo).toBe('user/test')
})
})

describe('provider field mapping', () => {
it('should map github.com to "github" provider', () => {
const mockRepoData: RepoData = {
name: 'test',
owner: 'user',
repo: 'user/test',
url: 'git@github.com:user/test.git',
branch: 'main',
provider: 'github',
httpsUrl: 'https://github.com/user/test',
}

expect(mockRepoData.provider).toBe('github')
})

it('should map gitlab.com to "gitlab" provider', () => {
const mockRepoData: RepoData = {
name: 'test',
owner: 'user',
repo: 'user/test',
url: 'git@gitlab.com:user/test.git',
branch: 'main',
provider: 'gitlab',
httpsUrl: 'https://gitlab.com/user/test',
}

expect(mockRepoData.provider).toBe('gitlab')
})
})
Comment on lines +8 to +119
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

Tests are self-fulfilling; exercise getRepoData instead.

These cases construct RepoData literals and assert the same values, so they don’t validate parsing/normalization behavior. Consider driving the tests through getRepoData (with mocked repo info) and asserting its output to catch regressions.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/utils/get-repo-data.test.ts` around lines 8 - 119, The tests are
self-fulfilling because they construct RepoData literals and assert those same
values; update each spec to call the real getRepoData function instead of
building RepoData objects so parsing/normalization is exercised. Replace the
mockRepoData literals with calls to getRepoData(...) (or the exported function
that returns RepoData) using the raw git URL/branch/name inputs from each case
(e.g., 'git@github.com:ownername/test.git', 'https://gitlab.com/...', custom
host, etc.), then assert on returnedRepo.httpsUrl, returnedRepo.provider and
returnedRepo.repo to validate behavior. Ensure you import getRepoData and keep
the same expected strings in assertions.

})
167 changes: 167 additions & 0 deletions tests/unit/utils/init/config-manual.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { RepoData } from '../../../../src/utils/get-repo-data.js'
import type { NetlifyAPI } from '@netlify/api'
import type BaseCommand from '../../../../src/commands/base-command.js'

const mockPrompt = vi.fn()
const mockLog = vi.fn()
const mockExit = vi.fn()
const mockCreateDeployKey = vi.fn()
const mockGetBuildSettings = vi.fn()
const mockSaveNetlifyToml = vi.fn()
const mockSetupSite = vi.fn()

vi.mock('inquirer', () => ({
default: {
prompt: mockPrompt,
},
}))

vi.mock('../../../../src/utils/command-helpers.js', () => ({
log: mockLog,
exit: mockExit,
}))

vi.mock('../../../../src/utils/init/utils.js', () => ({
createDeployKey: mockCreateDeployKey,
getBuildSettings: mockGetBuildSettings,
saveNetlifyToml: mockSaveNetlifyToml,
setupSite: mockSetupSite,
}))

describe('config-manual', () => {
let mockApi: Partial<NetlifyAPI>
let mockCommand: Pick<BaseCommand, 'netlify'>

beforeEach(() => {
vi.clearAllMocks()

mockApi = {}
mockCommand = {
netlify: {
api: mockApi as NetlifyAPI,
cachedConfig: { configPath: '/test/netlify.toml' } as BaseCommand['netlify']['cachedConfig'],
config: { plugins: [] } as BaseCommand['netlify']['config'],

Check failure on line 44 in tests/unit/utils/init/config-manual.test.ts

View workflow job for this annotation

GitHub Actions / typecheck

Conversion of type '{ plugins: never[]; }' to type 'NormalizedCachedConfigConfig' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
repositoryRoot: '/test',
Comment on lines +41 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files | grep -E "(config-manual\.test\.ts|base-command\.ts)"

Repository: netlify/cli

Length of output: 127


🏁 Script executed:

cat -n tests/unit/utils/init/config-manual.test.ts | sed -n '35,50p'

Repository: netlify/cli

Length of output: 608


🏁 Script executed:

head -100 src/commands/base-command.ts | cat -n

Repository: netlify/cli

Length of output: 4256


🏁 Script executed:

cat -n src/commands/base-command.ts | sed -n '100,250p'

Repository: netlify/cli

Length of output: 6552


🏁 Script executed:

cat -n src/commands/types.ts

Repository: netlify/cli

Length of output: 108


🏁 Script executed:

rg -t ts "type NetlifyOptions|interface NetlifyOptions" --max-count 5

Repository: netlify/cli

Length of output: 109


🏁 Script executed:

cat -n src/commands/types.d.ts

Repository: netlify/cli

Length of output: 1910


🏁 Script executed:

rg -t ts "type NormalizedCachedConfigConfig|interface NormalizedCachedConfigConfig" -A 15

Repository: netlify/cli

Length of output: 14867


🏁 Script executed:

rg -t ts "type CachedConfig|interface CachedConfig" src/lib/build.js -A 20

Repository: netlify/cli

Length of output: 111


🏁 Script executed:

fd build src/lib -t f | head -10

Repository: netlify/cli

Length of output: 71


🏁 Script executed:

rg "type CachedConfig|interface CachedConfig" --max-count 5

Repository: netlify/cli

Length of output: 154


🏁 Script executed:

rg "export interface CachedConfig" -A 30 src/lib/build.ts

Repository: netlify/cli

Length of output: 1068


🏁 Script executed:

rg "export interface CachedConfig" -A 60 src/lib/build.ts | head -80

Repository: netlify/cli

Length of output: 2080


🏁 Script executed:

rg "export interface CachedConfig" -A 150 src/lib/build.ts | grep -A 5 "plugins"

Repository: netlify/cli

Length of output: 499


🏁 Script executed:

cat -n tests/unit/utils/init/config-manual.test.ts | head -80

Repository: netlify/cli

Length of output: 3052


🏁 Script executed:

cat tsconfig.json | head -50

Repository: netlify/cli

Length of output: 94


🏁 Script executed:

cat tsconfig.base.json | head -80

Repository: netlify/cli

Length of output: 403


🏁 Script executed:

cd /tmp && cat > check_types.ts << 'EOF'
import type { CachedConfig } from '/repo/src/lib/build'
import type BaseCommand from '/repo/src/commands/base-command'

// This is what the test is trying to do
const testConfig: BaseCommand['netlify']['config'] = { plugins: [] }
EOF
npx tsc --noEmit check_types.ts 2>&1

Repository: netlify/cli

Length of output: 300


🏁 Script executed:

rg "export interface CachedConfig" -A 200 src/lib/build.ts | tail -50

Repository: netlify/cli

Length of output: 1462


Fix the failing type assertion on config.

Line 44 attempts an invalid direct cast. The config property requires a CachedConfig['config'] object with required properties like build, but only provides { plugins: [] }. Use a two-step cast through unknown:

Proposed fix
-        config: { plugins: [] } as BaseCommand['netlify']['config'],
+        config: { plugins: [] } as unknown as BaseCommand['netlify']['config'],
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
netlify: {
api: mockApi as NetlifyAPI,
cachedConfig: { configPath: '/test/netlify.toml' } as BaseCommand['netlify']['cachedConfig'],
config: { plugins: [] } as BaseCommand['netlify']['config'],
repositoryRoot: '/test',
netlify: {
api: mockApi as NetlifyAPI,
cachedConfig: { configPath: '/test/netlify.toml' } as BaseCommand['netlify']['cachedConfig'],
config: { plugins: [] } as unknown as BaseCommand['netlify']['config'],
repositoryRoot: '/test',
🧰 Tools
πŸͺ› GitHub Actions: Typecheck

[error] 44-44: TypeScript error TS2352: Conversion of type '{ plugins: never[]; }' to type 'NormalizedCachedConfigConfig' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. (During npm run typecheck/tsc)

πŸͺ› GitHub Check: typecheck

[failure] 44-44:
Conversion of type '{ plugins: never[]; }' to type 'NormalizedCachedConfigConfig' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/utils/init/config-manual.test.ts` around lines 41 - 45, The test
is failing due to an unsafe direct cast of the Netlify `config` property; locate
the object literal assigned to `netlify.config` in
tests/unit/utils/init/config-manual.test.ts and change the cast so the provided
`{ plugins: [] }` is first cast to `unknown` and then to the required
`CachedConfig['config']` (or BaseCommand['netlify']['config']) type instead of a
direct cast; this preserves the intended test shape while satisfying
TypeScript's structural checks for `build` and other required properties
referenced by `CachedConfig['config']`.

} as BaseCommand['netlify'],
}

mockPrompt.mockResolvedValue({
sshKeyAdded: true,
repoPath: 'git@gitlab.com:test/repo.git',
deployHookAdded: true,
})

mockCreateDeployKey.mockResolvedValue({ id: 'key-123', public_key: 'ssh-rsa test' })
mockGetBuildSettings.mockResolvedValue({
baseDir: '',
buildCmd: 'npm run build',
buildDir: 'dist',
functionsDir: 'functions',
pluginsToInstall: [],
})
mockSaveNetlifyToml.mockResolvedValue(undefined)
mockSetupSite.mockResolvedValue({ deploy_hook: 'https://api.netlify.com/hooks/test' })
})

describe('GitLab repository configuration', () => {
it('should use provider from repoData for GitLab repos', async () => {
const configManual = (await import('../../../../src/utils/init/config-manual.js')).default

const repoData: RepoData = {
name: 'test',
owner: 'ownername',
repo: 'ownername/test',
url: 'git@gitlab.com:ownername/test.git',
branch: 'main',
provider: 'gitlab',
httpsUrl: 'https://gitlab.com/ownername/test',
}

await configManual({
command: mockCommand as BaseCommand,
repoData,
siteId: 'site-123',
})

const setupCall = mockSetupSite.mock.calls[0][0] as { repo: { provider: string; repo_path: string } }
expect(setupCall.repo.provider).toBe('gitlab')
expect(setupCall.repo.repo_path).toBe('ownername/test')
})

it('should use repo path (owner/name format) instead of SSH URL for GitLab', async () => {
const configManual = (await import('../../../../src/utils/init/config-manual.js')).default

const repoData: RepoData = {
name: 'test',
owner: 'ownername',
repo: 'ownername/test',
url: 'git@gitlab.com:ownername/test.git',
branch: 'main',
provider: 'gitlab',
httpsUrl: 'https://gitlab.com/ownername/test',
}

await configManual({
command: mockCommand as BaseCommand,
repoData,
siteId: 'site-123',
})

const setupSiteCall = mockSetupSite.mock.calls[0][0] as {
repo: { repo_path: string }
}
expect(setupSiteCall.repo.repo_path).toBe('ownername/test')
expect(setupSiteCall.repo.repo_path).not.toBe('git@gitlab.com:ownername/test.git')
})

it('should fallback to manual provider when provider is null', async () => {
const configManual = (await import('../../../../src/utils/init/config-manual.js')).default

const repoData: RepoData = {
name: 'test',
owner: 'user',
repo: 'user/test',
url: 'git@custom.com:user/test.git',
branch: 'main',
provider: null,
httpsUrl: 'https://custom.com/user/test',
}

await configManual({
command: mockCommand as BaseCommand,
repoData,
siteId: 'site-123',
})

const setupCall = mockSetupSite.mock.calls[0][0] as { repo: { provider: string } }
expect(setupCall.repo.provider).toBe('manual')
})
})

describe('GitHub repository configuration', () => {
it('should use provider from repoData for GitHub repos', async () => {
const configManual = (await import('../../../../src/utils/init/config-manual.js')).default

const repoData: RepoData = {
name: 'test',
owner: 'user',
repo: 'user/test',
url: 'git@github.com:user/test.git',
branch: 'main',
provider: 'github',
httpsUrl: 'https://github.com/user/test',
}

await configManual({
command: mockCommand as BaseCommand,
repoData,
siteId: 'site-123',
})

const setupCall = mockSetupSite.mock.calls[0][0] as { repo: { provider: string; repo_path: string } }
expect(setupCall.repo.provider).toBe('github')
expect(setupCall.repo.repo_path).toBe('user/test')
})
})
})
Loading