From 472e5ee7c92116a3afae2dab223922371e5334e1 Mon Sep 17 00:00:00 2001 From: Sarah Etter Date: Fri, 13 Feb 2026 09:55:51 -0500 Subject: [PATCH 1/8] feat: fix handling of gitlab repos --- package-lock.json | 1 + src/utils/init/config-manual.ts | 4 +- tests/unit/utils/get-repo-data.test.ts | 120 +++++++++++++ tests/unit/utils/init/config-manual.test.ts | 179 ++++++++++++++++++++ 4 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 tests/unit/utils/get-repo-data.test.ts create mode 100644 tests/unit/utils/init/config-manual.test.ts diff --git a/package-lock.json b/package-lock.json index 850fc986971..2b8d045d074 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5813,6 +5813,7 @@ }, "node_modules/@parcel/watcher-wasm/node_modules/napi-wasm": { "version": "1.1.0", + "extraneous": true, "inBundle": true, "license": "MIT" }, diff --git a/src/utils/init/config-manual.ts b/src/utils/init/config-manual.ts index 7b8c6779a3e..90ff5477fee 100644 --- a/src/utils/init/config-manual.ts +++ b/src/utils/init/config-manual.ts @@ -88,8 +88,8 @@ export default async function configManual({ const repoPath = await getRepoPath({ repoData }) const repo = { - provider: 'manual', - repo_path: repoPath, + provider: repoData.provider ?? 'manual', + repo_path: repoData.repo ?? repoPath, repo_branch: repoData.branch, allowed_branches: [repoData.branch], deploy_key_id: deployKey.id, diff --git a/tests/unit/utils/get-repo-data.test.ts b/tests/unit/utils/get-repo-data.test.ts new file mode 100644 index 00000000000..feaa1c8fb36 --- /dev/null +++ b/tests/unit/utils/get-repo-data.test.ts @@ -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') + }) + }) +}) diff --git a/tests/unit/utils/init/config-manual.test.ts b/tests/unit/utils/init/config-manual.test.ts new file mode 100644 index 00000000000..099486385de --- /dev/null +++ b/tests/unit/utils/init/config-manual.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi, beforeEach, type Mock } from 'vitest' +import type { RepoData } from '../../../../src/utils/get-repo-data.js' +import type { NetlifyAPI } from '@netlify/api' + +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 + let mockCommand: any + + beforeEach(() => { + vi.clearAllMocks() + + mockApi = {} + mockCommand = { + netlify: { + api: mockApi, + cachedConfig: { configPath: '/test/netlify.toml' }, + config: { plugins: [] }, + repositoryRoot: '/test', + }, + } + + 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, + repoData, + siteId: 'site-123', + }) + + expect(mockSetupSite).toHaveBeenCalledWith( + expect.objectContaining({ + repo: expect.objectContaining({ + provider: 'gitlab', + repo_path: '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, + repoData, + siteId: 'site-123', + }) + + const setupSiteCall = (mockSetupSite as Mock).mock.calls[0][0] + 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, + repoData, + siteId: 'site-123', + }) + + expect(mockSetupSite).toHaveBeenCalledWith( + expect.objectContaining({ + repo: expect.objectContaining({ + provider: '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, + repoData, + siteId: 'site-123', + }) + + expect(mockSetupSite).toHaveBeenCalledWith( + expect.objectContaining({ + repo: expect.objectContaining({ + provider: 'github', + repo_path: 'user/test', + }), + }), + ) + }) + }) +}) From a2b718198f7bbf955f7114fd80836f881e94f968 Mon Sep 17 00:00:00 2001 From: Sarah Etter Date: Fri, 13 Feb 2026 10:09:42 -0500 Subject: [PATCH 2/8] fix: types --- tests/unit/utils/init/config-manual.test.ts | 56 ++++++++------------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/tests/unit/utils/init/config-manual.test.ts b/tests/unit/utils/init/config-manual.test.ts index 099486385de..0cdc807d28f 100644 --- a/tests/unit/utils/init/config-manual.test.ts +++ b/tests/unit/utils/init/config-manual.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it, vi, beforeEach, type Mock } from 'vitest' +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() @@ -30,7 +31,7 @@ vi.mock('../../../../src/utils/init/utils.js', () => ({ describe('config-manual', () => { let mockApi: Partial - let mockCommand: any + let mockCommand: Pick beforeEach(() => { vi.clearAllMocks() @@ -38,11 +39,11 @@ describe('config-manual', () => { mockApi = {} mockCommand = { netlify: { - api: mockApi, - cachedConfig: { configPath: '/test/netlify.toml' }, - config: { plugins: [] }, + api: mockApi as NetlifyAPI, + cachedConfig: { configPath: '/test/netlify.toml' } as BaseCommand['netlify']['cachedConfig'], + config: { plugins: [] } as BaseCommand['netlify']['config'], repositoryRoot: '/test', - }, + } as BaseCommand['netlify'], } mockPrompt.mockResolvedValue({ @@ -78,19 +79,14 @@ describe('config-manual', () => { } await configManual({ - command: mockCommand, + command: mockCommand as BaseCommand, repoData, siteId: 'site-123', }) - expect(mockSetupSite).toHaveBeenCalledWith( - expect.objectContaining({ - repo: expect.objectContaining({ - provider: 'gitlab', - repo_path: 'ownername/test', - }), - }), - ) + 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 () => { @@ -107,12 +103,14 @@ describe('config-manual', () => { } await configManual({ - command: mockCommand, + command: mockCommand as BaseCommand, repoData, siteId: 'site-123', }) - const setupSiteCall = (mockSetupSite as Mock).mock.calls[0][0] + 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') }) @@ -131,18 +129,13 @@ describe('config-manual', () => { } await configManual({ - command: mockCommand, + command: mockCommand as BaseCommand, repoData, siteId: 'site-123', }) - expect(mockSetupSite).toHaveBeenCalledWith( - expect.objectContaining({ - repo: expect.objectContaining({ - provider: 'manual', - }), - }), - ) + const setupCall = mockSetupSite.mock.calls[0][0] as { repo: { provider: string } } + expect(setupCall.repo.provider).toBe('manual') }) }) @@ -161,19 +154,14 @@ describe('config-manual', () => { } await configManual({ - command: mockCommand, + command: mockCommand as BaseCommand, repoData, siteId: 'site-123', }) - expect(mockSetupSite).toHaveBeenCalledWith( - expect.objectContaining({ - repo: expect.objectContaining({ - provider: 'github', - repo_path: 'user/test', - }), - }), - ) + 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') }) }) }) From bcddb6224a516ca9d5f02ece3bb54cd40a4caced Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Wed, 18 Feb 2026 11:49:37 -0500 Subject: [PATCH 3/8] chore: improve reliability of deploy integration tests (#7951) * ci: create integration test sites in testing account This [mechanism already exists](https://github.com/netlify/cli/blob/b80b98f85929803fc35a08458c9327dc7ef63de0/tests/integration/utils/create-live-test-site.ts#L22-L36) but wasn't being used here, so it was falling back to the first account the user has access to that is returned by the accounts API. The `netlify-integration-testing` account is properly configured to host our various integration test sites, e.g. to avoid being rate limited. * ci: try DEBUG_TESTS=1 * Revert "ci: try DEBUG_TESTS=1" This reverts commit 2d186bf27ed33cf84310a1f6aa9a63b90b41bae5. * chore: bump deploy integration test timeout and concurrency --- .github/workflows/integration-tests.yml | 1 + tests/integration/commands/deploy/deploy.test.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 3045479171a..9cb43bbc6eb 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -69,6 +69,7 @@ jobs: # We set a flag so we can skip tests that access Netlify API NETLIFY_TEST_DISABLE_LIVE: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }} + NETLIFY_TEST_ACCOUNT_SLUG: 'netlify-integration-testing' NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} # NETLIFY_TEST_GITHUB_TOKEN is used to avoid reaching GitHub API limits in exec-fetcher.js NETLIFY_TEST_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/tests/integration/commands/deploy/deploy.test.ts b/tests/integration/commands/deploy/deploy.test.ts index bb7bb5e9086..87e8e22b026 100644 --- a/tests/integration/commands/deploy/deploy.test.ts +++ b/tests/integration/commands/deploy/deploy.test.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from 'url' import { load } from 'cheerio' import execa from 'execa' import fetch from 'node-fetch' -import { afterAll, beforeAll, describe, expect, test } from 'vitest' +import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest' import { callCli } from '../../utils/call-cli.js' import { createLiveTestSite, generateSiteName } from '../../utils/create-live-test-site.js' @@ -86,7 +86,13 @@ const context: { account: unknown; siteId: string } = { account: undefined, } -describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('commands/deploy', () => { +const disableLiveTests = process.env.NETLIFY_TEST_DISABLE_LIVE === 'true' + +// Running multiple entire build + deploy cycles concurrently results in a lot of network requests that may +// cause resource contention anyway, so lower the default concurrency from 5 to 3. +vi.setConfig({ maxConcurrency: 3 }) + +describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_000 }, () => { beforeAll(async () => { const { account, siteId } = await createLiveTestSite(SITE_NAME) context.siteId = siteId From 7fda9dd4f2ddef02601ec37fb4b0242875a1653d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Thu, 19 Feb 2026 11:44:46 +0000 Subject: [PATCH 4/8] feat: begin integrating `@netlify/dev` (#7950) * feat: begin integrating `@netlify/dev` * chore: add deps * fix(deps): bump netlify packages to dedupe with @netlify/dev * chore: update deps * chore: fix lint issue * chore: add debug logging * chore: add comment Co-authored-by: Philippe Serhal * chore: cap site name length in tests * chore: update snapshot --------- Co-authored-by: Philippe Serhal --- package-lock.json | 455 ++++++++++-------- package.json | 13 +- src/commands/blobs/blobs-set.ts | 1 + src/commands/dev/dev.ts | 19 +- src/commands/dev/programmatic-netlify-dev.ts | 62 +++ .../framework-detection.test.ts.snap | 4 +- .../dev/dev.programmatic-netlify-dev.test.ts | 69 +++ tests/integration/utils/site-builder.ts | 19 +- 8 files changed, 436 insertions(+), 206 deletions(-) create mode 100644 src/commands/dev/programmatic-netlify-dev.ts create mode 100644 tests/integration/commands/dev/dev.programmatic-netlify-dev.test.ts diff --git a/package-lock.json b/package-lock.json index 53c6c6f0aaf..3aad0cba7a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,18 +11,19 @@ "license": "MIT", "dependencies": { "@fastify/static": "9.0.0", - "@netlify/ai": "0.3.4", + "@netlify/ai": "0.3.8", "@netlify/api": "14.0.14", - "@netlify/blobs": "10.1.0", + "@netlify/blobs": "10.7.0", "@netlify/build": "35.7.1", "@netlify/build-info": "10.3.0", "@netlify/config": "24.4.0", - "@netlify/dev-utils": "4.3.2", + "@netlify/dev": "4.11.2", + "@netlify/dev-utils": "4.3.3", "@netlify/edge-bundler": "14.9.8", "@netlify/edge-functions": "3.0.3", "@netlify/edge-functions-bootstrap": "2.17.1", "@netlify/headers-parser": "9.0.2", - "@netlify/images": "1.2.5", + "@netlify/images": "1.3.3", "@netlify/local-functions-proxy": "2.0.3", "@netlify/redirect-parser": "15.0.3", "@netlify/zip-it-and-ship-it": "14.3.2", @@ -115,8 +116,8 @@ "@bugsnag/js": "8.6.0", "@eslint/compat": "1.4.1", "@eslint/js": "9.36.0", - "@netlify/functions": "5.1.0", - "@netlify/types": "2.2.0", + "@netlify/functions": "5.1.2", + "@netlify/types": "2.3.0", "@sindresorhus/slugify": "3.0.0", "@tsconfig/node18": "18.2.4", "@tsconfig/recommended": "1.0.13", @@ -2478,17 +2479,14 @@ } }, "node_modules/@netlify/ai": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@netlify/ai/-/ai-0.3.4.tgz", - "integrity": "sha512-mV0RtkO5dOwbuqRn/Sn0aHIV4j6sw8B4F16WCx0GYBRcJ9IbBkzvuEzW0IDUbNE6hxu9FFs5WRDASDJpgDY1ZQ==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@netlify/ai/-/ai-0.3.8.tgz", + "integrity": "sha512-qz8XDb/82UzsUMKn+sB84V3ZGqeNQOvGwNo840nHIV9saJwLPTd+FOqSUoKUIxZphNA7kQ0uGeadSUkJzDz7og==", "dependencies": { - "@netlify/api": "^14.0.11" + "@netlify/api": "^14.0.14" }, "engines": { "node": ">=20.6.1" - }, - "peerDependencies": { - "@netlify/api": ">=14.0.11" } }, "node_modules/@netlify/api": { @@ -2539,84 +2537,19 @@ "integrity": "sha512-4wMPu9iN3/HL97QblBsBay3E1etIciR84izI3U+4iALY+JHCrI+a2jO0qbAZ/nxKoegypYEaiiqWXylm+/zfrw==" }, "node_modules/@netlify/blobs": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-10.1.0.tgz", - "integrity": "sha512-dFpqDc6/x5LEu9L7kblCQu00CFEchH8J42jmQoXPuhKoE7avajzeLTbVKA8Olk3S/c2m9ejegrgbhL8NRA2Jyw==", + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-10.7.0.tgz", + "integrity": "sha512-wuiaKRbRLG/L049yR+7/p7xSKa4jx6JRBnweRYwP6mMYn9D+x/wccPgsxEMtKqthmow6frs7ZSrNYTt9U3yUdQ==", "license": "MIT", "dependencies": { - "@netlify/dev-utils": "4.3.0", - "@netlify/runtime-utils": "2.2.0" + "@netlify/dev-utils": "4.3.3", + "@netlify/otel": "^5.1.1", + "@netlify/runtime-utils": "2.3.0" }, "engines": { "node": "^14.16.0 || >=16.0.0" } }, - "node_modules/@netlify/blobs/node_modules/@netlify/dev-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.3.0.tgz", - "integrity": "sha512-vZAL8pMuj3yPQlmHSgyaA/UQFxc6pZgU0LucFJ1+IPWGJtIzBXHRvuR4acpoP72HtyQPUHJ42s7U9GaaSGVNHg==", - "license": "MIT", - "dependencies": { - "@whatwg-node/server": "^0.10.0", - "ansis": "^4.1.0", - "chokidar": "^4.0.1", - "decache": "^4.6.2", - "dettle": "^1.0.5", - "dot-prop": "9.0.0", - "empathic": "^2.0.0", - "env-paths": "^3.0.0", - "image-size": "^2.0.2", - "js-image-generator": "^1.0.4", - "parse-gitignore": "^2.0.0", - "semver": "^7.7.2", - "tmp-promise": "^3.0.3", - "uuid": "^11.1.0", - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": "^18.14.0 || >=20" - } - }, - "node_modules/@netlify/blobs/node_modules/dot-prop": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", - "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^4.18.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@netlify/blobs/node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@netlify/blobs/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/@netlify/build": { "version": "35.7.1", "resolved": "https://registry.npmjs.org/@netlify/build/-/build-35.7.1.tgz", @@ -2746,68 +2679,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@netlify/build/node_modules/@netlify/blobs": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-10.6.0.tgz", - "integrity": "sha512-orUfaNjUg0SDCRt/Zhtl1v3nCjYWb1NVqKwbB92lqpJWpHRZezxFViOoUoxv5UgHaXtjxgLitE24lL3hUm1bmg==", - "license": "MIT", - "dependencies": { - "@netlify/dev-utils": "4.3.3", - "@netlify/otel": "^5.1.1", - "@netlify/runtime-utils": "2.3.0" - }, - "engines": { - "node": "^14.16.0 || >=16.0.0" - } - }, - "node_modules/@netlify/build/node_modules/@netlify/dev-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.3.3.tgz", - "integrity": "sha512-qziF8R9kf7mRNgSpmUH96O0aV1ZiwK4c9ZecFQbDSQuYhgy9GY1WTjiQF0oQnohjTjWNtXhrU39LAeXWNLaBJg==", - "license": "MIT", - "dependencies": { - "@whatwg-node/server": "^0.10.0", - "ansis": "^4.1.0", - "chokidar": "^4.0.1", - "decache": "^4.6.2", - "dettle": "^1.0.5", - "dot-prop": "9.0.0", - "empathic": "^2.0.0", - "env-paths": "^3.0.0", - "image-size": "^2.0.2", - "js-image-generator": "^1.0.4", - "parse-gitignore": "^2.0.0", - "semver": "^7.7.2", - "tmp-promise": "^3.0.3", - "uuid": "^13.0.0", - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": "^18.14.0 || >=20" - } - }, - "node_modules/@netlify/build/node_modules/@netlify/dev-utils/node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/@netlify/build/node_modules/@netlify/runtime-utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.3.0.tgz", - "integrity": "sha512-cW8weDvsKV7zfia2m5EcBy6KILGoPD+eYZ3qWNGnIo05DGF28goPES0xKSDkNYgAF/2rRSIhie2qcBhbGVgSRg==", - "license": "MIT", - "engines": { - "node": "^18.14.0 || >=20" - } - }, "node_modules/@netlify/build/node_modules/@sindresorhus/slugify": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", @@ -2824,33 +2695,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@netlify/build/node_modules/dot-prop": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", - "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^4.18.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@netlify/build/node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@netlify/build/node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -3007,6 +2851,18 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/@netlify/cache": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@netlify/cache/-/cache-3.3.5.tgz", + "integrity": "sha512-u4wx2se/wRvLsU/sQlT5ruofEwMjo5kg6ybEdQLuIswH6+6+9BCFF8VX4ByBP3MZJl3/pxExmcPiFqo0TBP3tg==", + "license": "MIT", + "dependencies": { + "@netlify/runtime-utils": "2.3.0" + }, + "engines": { + "node": ">=20.6.1" + } + }, "node_modules/@netlify/cache-utils": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@netlify/cache-utils/-/cache-utils-6.0.4.tgz", @@ -3231,10 +3087,64 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/@netlify/db-dev": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@netlify/db-dev/-/db-dev-0.2.0.tgz", + "integrity": "sha512-EDRRU26K3RrVf0yKvV+i2ShaNM3Ow4M0/EKAejD6WJDFoJ+ML/NVRGdsX41vWNvCb9VVaRVAIeXEUwgzXR7CUw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@electric-sql/pglite": "^0.3.15", + "pg-gateway": "0.3.0-beta.4" + }, + "engines": { + "node": ">=20.6.1" + } + }, + "node_modules/@netlify/db-dev/node_modules/@electric-sql/pglite": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", + "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true + }, + "node_modules/@netlify/dev": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@netlify/dev/-/dev-4.11.2.tgz", + "integrity": "sha512-quIKbuG7xD3yiWExuZA1Xl/b3Wc+/V3QjrNT4XX5m+dA/D54J61dABf1IGwQYNZHKiF5rVL7D3AwHNAojVQzuw==", + "license": "MIT", + "dependencies": { + "@netlify/ai": "^0.3.8", + "@netlify/blobs": "10.7.0", + "@netlify/config": "^24.4.0", + "@netlify/dev-utils": "4.3.3", + "@netlify/edge-functions-dev": "1.0.11", + "@netlify/functions-dev": "1.1.12", + "@netlify/headers": "2.1.3", + "@netlify/images": "1.3.3", + "@netlify/redirects": "3.1.5", + "@netlify/runtime": "4.1.15", + "@netlify/static": "3.1.3", + "ulid": "^3.0.0" + }, + "engines": { + "node": ">=20.6.1" + }, + "peerDependencies": { + "@netlify/db-dev": "0.2.0" + }, + "peerDependenciesMeta": { + "@netlify/db-dev": { + "optional": true + } + } + }, "node_modules/@netlify/dev-utils": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.3.2.tgz", - "integrity": "sha512-Nl6c5UVLbpOwvzVaT6fJycdkc3EswqFoI9c2hZ3WUUX+kQ2ojdrkFMuKcPERaGXYxrhy/uGk1CURAflG8YC2RA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.3.3.tgz", + "integrity": "sha512-qziF8R9kf7mRNgSpmUH96O0aV1ZiwK4c9ZecFQbDSQuYhgy9GY1WTjiQF0oQnohjTjWNtXhrU39LAeXWNLaBJg==", "license": "MIT", "dependencies": { "@whatwg-node/server": "^0.10.0", @@ -3981,28 +3891,94 @@ "integrity": "sha512-KyNJbDhK1rC5wEeI7bXPgfl8QvADMHqNy2nwNJG60EHVRXTF0zxFnOpt/p0m2C512gcMXRrKZxaOZQ032RHVbw==", "license": "MIT" }, - "node_modules/@netlify/edge-functions/node_modules/@netlify/types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.3.0.tgz", - "integrity": "sha512-5gxMWh/S7wr0uHKSTbMv4bjWmWSpwpeLYvErWeVNAPll5/QNFo9aWimMAUuh8ReLY3/fg92XAroVVu7+z27Snw==", + "node_modules/@netlify/edge-functions-dev": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@netlify/edge-functions-dev/-/edge-functions-dev-1.0.11.tgz", + "integrity": "sha512-ASybM6fkopOKHorwI1TwsbBlF+eZQCMmlddWtSpyWDunKc4P7i/FEkzdTinNQvcLX7RIGOwjrxanTMEOen/Zvg==", "license": "MIT", + "dependencies": { + "@netlify/dev-utils": "4.3.3", + "@netlify/edge-bundler": "^14.9.8", + "@netlify/edge-functions": "3.0.3", + "@netlify/edge-functions-bootstrap": "2.16.0", + "@netlify/runtime-utils": "2.3.0", + "get-port": "^7.1.0" + }, "engines": { - "node": "^18.14.0 || >=20" + "node": ">=20.6.1" + } + }, + "node_modules/@netlify/edge-functions-dev/node_modules/@netlify/edge-functions-bootstrap": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@netlify/edge-functions-bootstrap/-/edge-functions-bootstrap-2.16.0.tgz", + "integrity": "sha512-v8QQihSbBHj3JxtJsHoepXALpNumD9M7egHoc8z62FYl5it34dWczkaJoFFopEyhiBVKi4K/n0ZYpdzwfujd6g==", + "license": "MIT" + }, + "node_modules/@netlify/edge-functions-dev/node_modules/get-port": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", + "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@netlify/functions": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-5.1.0.tgz", - "integrity": "sha512-LZtiQtf/QzPHIeNDZuIBxx04kmU7lCipWqZ26ejX7mYSB3yj2wvpZfF49kD8B8FoKTydSvgFmBpIcCO5FvpEXA==", - "dev": true, + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-5.1.2.tgz", + "integrity": "sha512-tpPiLSkQatuexH8AdAZ8RlALvT7ixOE9VhvpkzQGNvihcms8hzmvUDuSxQa7UneTj/sHsdirnXmnJ+nmf+Nx/w==", "license": "MIT", "dependencies": { - "@netlify/types": "2.2.0" + "@netlify/types": "2.3.0" }, "engines": { "node": ">=18.0.0" } }, + "node_modules/@netlify/functions-dev": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@netlify/functions-dev/-/functions-dev-1.1.12.tgz", + "integrity": "sha512-rWFCthzhKiuM62yPd2upDY9+y/Ww5ahEKA8+E8WS8dgQKg+5/G/Yka6zfPNpkkFM3/oNi0R7LffWn2DMxrTMPQ==", + "license": "MIT", + "dependencies": { + "@netlify/blobs": "10.7.0", + "@netlify/dev-utils": "4.3.3", + "@netlify/functions": "5.1.2", + "@netlify/zip-it-and-ship-it": "^14.3.2", + "cron-parser": "^4.9.0", + "decache": "^4.6.2", + "extract-zip": "^2.0.1", + "is-stream": "^4.0.1", + "jwt-decode": "^4.0.0", + "lambda-local": "^2.2.0", + "read-package-up": "^11.0.0", + "semver": "^7.6.3", + "source-map-support": "^0.5.21" + }, + "engines": { + "node": ">=20.6.1" + } + }, + "node_modules/@netlify/functions-dev/node_modules/read-package-up": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", + "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0", + "read-pkg": "^9.0.0", + "type-fest": "^4.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@netlify/functions-utils": { "version": "6.2.22", "resolved": "https://registry.npmjs.org/@netlify/functions-utils/-/functions-utils-6.2.22.tgz", @@ -4134,6 +4110,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@netlify/headers": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@netlify/headers/-/headers-2.1.3.tgz", + "integrity": "sha512-jVjhHokAQLGI5SJA2nj8OWeNQ7ASV4m0n4aiR4PHrhM8ot385V2BbUGkSpC28M92uqP0l1cbAQaSoSOU4re8iQ==", + "license": "MIT", + "dependencies": { + "@netlify/headers-parser": "^9.0.2" + }, + "engines": { + "node": ">=20.6.1" + } + }, "node_modules/@netlify/headers-parser": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/@netlify/headers-parser/-/headers-parser-9.0.2.tgz", @@ -4151,9 +4139,9 @@ } }, "node_modules/@netlify/images": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@netlify/images/-/images-1.2.5.tgz", - "integrity": "sha512-kTcM86Zpzne46RDQJO5o0rDEryYbBpRk7+8NaWLYP6ChM13MdLYwk9nLYyh4APWB2Zx9JBvBJO3Q/lKiF20zXg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@netlify/images/-/images-1.3.3.tgz", + "integrity": "sha512-1X3fUmacCLMlPIqyeV5tdo6Wbf9aBSWobgr4DyRvg9zDV9jbKqgdN3BNbcUXmVaqfN+0iiv0k9p02mcRV3OyOw==", "license": "MIT", "dependencies": { "ipx": "^3.1.1" @@ -4432,6 +4420,22 @@ "node": ">=18.14.0" } }, + "node_modules/@netlify/redirects": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@netlify/redirects/-/redirects-3.1.5.tgz", + "integrity": "sha512-yU4YBqRYoqPobg/u96QI07IuevAc8+tVLAcnty6/vBJAlo5d7E72r+U6dez48EPGIJHY5hEQK4jT0m9SmKg8mg==", + "license": "MIT", + "dependencies": { + "@netlify/dev-utils": "4.3.3", + "@netlify/redirect-parser": "^15.0.3", + "cookie": "^1.0.2", + "jsonwebtoken": "9.0.3", + "netlify-redirector": "^0.5.0" + }, + "engines": { + "node": ">=20.6.1" + } + }, "node_modules/@netlify/run-utils": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@netlify/run-utils/-/run-utils-6.0.2.tgz", @@ -4545,10 +4549,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@netlify/runtime": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/@netlify/runtime/-/runtime-4.1.15.tgz", + "integrity": "sha512-drX5NYNnqAMKnYsStRT8Q1ruNqd68QdMGdakdMtMb/aTaAtPCIug66BPP98YSWvgv9r7O5eO4NX/Ma7UkMVwvQ==", + "license": "MIT", + "dependencies": { + "@netlify/blobs": "^10.7.0", + "@netlify/cache": "3.3.5", + "@netlify/runtime-utils": "2.3.0", + "@netlify/types": "2.3.0" + }, + "engines": { + "node": ">=20.6.1" + } + }, "node_modules/@netlify/runtime-utils": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.2.0.tgz", - "integrity": "sha512-K3kWIxIMucibzQsATU2xw2JI+OpS9PZfPW/a+81gmeLC8tLv5YAxTVT0NFY/3imk1kcOJb9g7658jPLqDJaiAw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.3.0.tgz", + "integrity": "sha512-cW8weDvsKV7zfia2m5EcBy6KILGoPD+eYZ3qWNGnIo05DGF28goPES0xKSDkNYgAF/2rRSIhie2qcBhbGVgSRg==", "license": "MIT", "engines": { "node": "^18.14.0 || >=20" @@ -4563,11 +4582,47 @@ "node": ">=18.0.0" } }, + "node_modules/@netlify/static": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@netlify/static/-/static-3.1.3.tgz", + "integrity": "sha512-88VG2jwWY1eOT/IiMbkrak7qyo+t7om0v731i63JiCDfXjCEp+yFPNr9L4v8S6wcCmgnkGQ6Sr5roF1sEtp6+Q==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">=20.6.1" + } + }, + "node_modules/@netlify/static/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@netlify/static/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@netlify/types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.2.0.tgz", - "integrity": "sha512-XOWlZ2wPpdRKkAOcQbjIf/Qz7L4RjcSVINVNQ9p3F6U8V6KSEOsB3fPrc6Ly8EOeJioHUepRPuzHzJE/7V5EsA==", - "dev": true, + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.3.0.tgz", + "integrity": "sha512-5gxMWh/S7wr0uHKSTbMv4bjWmWSpwpeLYvErWeVNAPll5/QNFo9aWimMAUuh8ReLY3/fg92XAroVVu7+z27Snw==", "license": "MIT", "engines": { "node": "^18.14.0 || >=20" @@ -16965,6 +17020,14 @@ "dev": true, "license": "MIT" }, + "node_modules/pg-gateway": { + "version": "0.3.0-beta.4", + "resolved": "https://registry.npmjs.org/pg-gateway/-/pg-gateway-0.3.0-beta.4.tgz", + "integrity": "sha512-CTjsM7Z+0Nx2/dyZ6r8zRsc3f9FScoD5UAOlfUx1Fdv/JOIWvRbF7gou6l6vP+uypXQVoYPgw8xZDXgMGvBa4Q==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 4476e3adb2c..b2c95d02913 100644 --- a/package.json +++ b/package.json @@ -58,18 +58,19 @@ }, "dependencies": { "@fastify/static": "9.0.0", - "@netlify/ai": "0.3.4", + "@netlify/ai": "0.3.8", "@netlify/api": "14.0.14", - "@netlify/blobs": "10.1.0", + "@netlify/blobs": "10.7.0", "@netlify/build": "35.7.1", "@netlify/build-info": "10.3.0", "@netlify/config": "24.4.0", - "@netlify/dev-utils": "4.3.2", + "@netlify/dev": "4.11.2", + "@netlify/dev-utils": "4.3.3", "@netlify/edge-bundler": "14.9.8", "@netlify/edge-functions": "3.0.3", "@netlify/edge-functions-bootstrap": "2.17.1", "@netlify/headers-parser": "9.0.2", - "@netlify/images": "1.2.5", + "@netlify/images": "1.3.3", "@netlify/local-functions-proxy": "2.0.3", "@netlify/redirect-parser": "15.0.3", "@netlify/zip-it-and-ship-it": "14.3.2", @@ -158,8 +159,8 @@ "@bugsnag/js": "8.6.0", "@eslint/compat": "1.4.1", "@eslint/js": "9.36.0", - "@netlify/functions": "5.1.0", - "@netlify/types": "2.2.0", + "@netlify/functions": "5.1.2", + "@netlify/types": "2.3.0", "@sindresorhus/slugify": "3.0.0", "@tsconfig/node18": "18.2.4", "@tsconfig/recommended": "1.0.13", diff --git a/src/commands/blobs/blobs-set.ts b/src/commands/blobs/blobs-set.ts index 6ecc4d94e1d..9378007c9b0 100644 --- a/src/commands/blobs/blobs-set.ts +++ b/src/commands/blobs/blobs-set.ts @@ -60,6 +60,7 @@ export const blobsSet = async ( if (force === undefined) { const existingValue = await store.get(key) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (existingValue) { await promptBlobSetOverwrite(key, storeName) } diff --git a/src/commands/dev/dev.ts b/src/commands/dev/dev.ts index e92b994689e..13d71ff7b71 100644 --- a/src/commands/dev/dev.ts +++ b/src/commands/dev/dev.ts @@ -21,7 +21,13 @@ import { import detectServerSettings, { getConfigWithPlugins } from '../../utils/detect-server-settings.js' import { parseAIGatewayContext, setupAIGateway } from '@netlify/ai/bootstrap' -import { UNLINKED_SITE_MOCK_ID, getDotEnvVariables, getSiteInformation, injectEnvVariables } from '../../utils/dev.js' +import { + UNLINKED_SITE_MOCK_ID, + getDotEnvVariables, + getSiteInformation, + injectEnvVariables, + processOnExit, +} from '../../utils/dev.js' import { getEnvelopeEnv } from '../../utils/env/index.js' import { ensureNetlifyIgnore } from '../../utils/gitignore.js' import { getLiveTunnelSlug, startLiveTunnel } from '../../utils/live-tunnel.js' @@ -35,6 +41,7 @@ import { getBaseOptionValues } from '../base-command.js' import type { NetlifySite } from '../types.js' import type { DevConfig } from './types.js' +import { startNetlifyDev as startProgrammaticNetlifyDev } from './programmatic-netlify-dev.js' import { doesProjectRequireLinkedSite } from '../../lib/extensions.js' const handleLiveTunnel = async ({ @@ -174,6 +181,16 @@ export const dev = async (options: OptionValues, command: BaseCommand) => { injectEnvVariables(env) + const programmaticNetlifyDev = await startProgrammaticNetlifyDev({ + projectRoot: command.workingDir, + apiToken: api.accessToken ?? undefined, + env, + }) + + if (programmaticNetlifyDev) { + processOnExit(() => programmaticNetlifyDev.stop()) + } + await promptEditorHelper({ chalk, config, log, NETLIFYDEVLOG, repositoryRoot, state }) let settings: ServerSettings diff --git a/src/commands/dev/programmatic-netlify-dev.ts b/src/commands/dev/programmatic-netlify-dev.ts new file mode 100644 index 00000000000..deab3d1cfd5 --- /dev/null +++ b/src/commands/dev/programmatic-netlify-dev.ts @@ -0,0 +1,62 @@ +import process from 'process' + +import { NetlifyDev } from '@netlify/dev' + +import { NETLIFYDEVWARN, log } from '../../utils/command-helpers.js' +import type { EnvironmentVariables } from '../../utils/types.js' + +interface StartNetlifyDevOptions { + projectRoot: string + apiToken: string | undefined + env: EnvironmentVariables +} + +/** + * Much of the core of local dev emulation of the Netlify platform was extracted + * (duplicated) to https://github.com/netlify/primitives. This is a shim that + * gradually enables *some* of this extracted functionality while falling back + * to the legacy copy in this codebase for the rest. + * + * TODO: Hook this up to the request chain and fall through to the existing handler. + * TODO: `@netlify/images` follows a different pattern (it is used directly). + * Move that here. + */ +export const startNetlifyDev = async ({ + apiToken, + env, + projectRoot, +}: StartNetlifyDevOptions): Promise => { + if (process.env.EXPERIMENTAL_NETLIFY_DB_ENABLED !== '1') { + return + } + + const netlifyDev = new NetlifyDev({ + projectRoot, + apiToken, + ...(process.env.NETLIFY_API_URL && { apiURL: process.env.NETLIFY_API_URL }), + + aiGateway: { enabled: false }, + blobs: { enabled: false }, + edgeFunctions: { enabled: false }, + environmentVariables: { enabled: false }, + functions: { enabled: false }, + geolocation: { enabled: false }, + headers: { enabled: false }, + images: { enabled: false }, + redirects: { enabled: false }, + staticFiles: { enabled: false }, + serverAddress: null, + }) + + try { + await netlifyDev.start() + } catch (error) { + log(`${NETLIFYDEVWARN} Failed to start @netlify/dev: ${error instanceof Error ? error.message : String(error)}`) + } + + if (process.env.NETLIFY_DB_URL) { + env.NETLIFY_DB_URL = { sources: ['internal'], value: process.env.NETLIFY_DB_URL } + } + + return netlifyDev +} diff --git a/tests/integration/__snapshots__/framework-detection.test.ts.snap b/tests/integration/__snapshots__/framework-detection.test.ts.snap index 6a86a5e7cbe..829c8d25346 100644 --- a/tests/integration/__snapshots__/framework-detection.test.ts.snap +++ b/tests/integration/__snapshots__/framework-detection.test.ts.snap @@ -152,7 +152,7 @@ exports[`frameworks/framework-detection > should use static server when framewor ⬥ Unable to determine public folder to serve files from. Using current working directory ⬥ Setup a netlify.toml file with a [dev] section to specify your dev server settings. ⬥ See docs at: https://docs.netlify.com/cli/local-development/#project-detection -⬥ Running static server from \\"should-use-static-server-when-framework-is-set-to-static\\" +⬥ Running static server from \\"should-use-static-server-when-framework-i-cabde4ea\\" ⬥ Setting up local dev server ⬥ Static server listening to @@ -168,7 +168,7 @@ exports[`frameworks/framework-detection > should warn if using static server and "⬥ Using simple static server because '--dir' flag was specified ⬥ Ignoring 'targetPort' setting since using a simple static server. ⬥ Use --staticServerPort or [dev.staticServerPort] to configure the static server port -⬥ Running static server from \\"should-warn-if-using-static-server-and-target-port-is-configured/public\\" +⬥ Running static server from \\"should-warn-if-using-static-server-and-ta-45f6af30/public\\" ⬥ Setting up local dev server ⬥ Static server listening to diff --git a/tests/integration/commands/dev/dev.programmatic-netlify-dev.test.ts b/tests/integration/commands/dev/dev.programmatic-netlify-dev.test.ts new file mode 100644 index 00000000000..304c4fcef13 --- /dev/null +++ b/tests/integration/commands/dev/dev.programmatic-netlify-dev.test.ts @@ -0,0 +1,69 @@ +import fetch from 'node-fetch' +import { describe, test } from 'vitest' + +import { withDevServer } from '../../utils/dev-server.js' +import { withSiteBuilder } from '../../utils/site-builder.js' + +describe('@netlify/dev integration', () => { + test('Makes DB available to functions when EXPERIMENTAL_NETLIFY_DB_ENABLED is set', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder + .withPackageJson({ + packageJson: { + dependencies: { '@netlify/db': '0.1.0', '@netlify/db-dev': '0.2.0' }, + }, + }) + .withCommand({ command: ['npm', 'install'] }) + .withContentFile({ + path: 'netlify/functions/db-test.mjs', + content: ` + import { getDatabase } from "@netlify/db"; + + export default async () => { + try { + const { sql } = getDatabase(); + const rows = await sql\`SELECT 1 + 1 AS sum\`; + return Response.json({ sum: rows[0].sum }); + } catch (error) { + return Response.json({ error: error.message }, { status: 500 }); + } + }; + + export const config = { path: "/db-test" }; + `, + }) + + await builder.build() + + await withDevServer({ cwd: builder.directory, env: { EXPERIMENTAL_NETLIFY_DB_ENABLED: '1' } }, async (server) => { + const response = await fetch(`${server.url}/db-test`) + const body = await response.text() + console.log(body) + t.expect(body).toEqual(JSON.stringify({ sum: 2 })) + }) + }) + }) + + test('Does not set NETLIFY_DB_URL when EXPERIMENTAL_NETLIFY_DB_ENABLED is not set', async (t) => { + await withSiteBuilder(t, async (builder) => { + builder.withFunction({ + path: 'db-url.mjs', + pathPrefix: 'netlify/functions', + runtimeAPIVersion: 2, + config: { path: '/db-url' }, + handler: () => Response.json({ url: process.env.NETLIFY_DB_URL ?? '' }), + }) + + await builder.build() + + await withDevServer({ cwd: builder.directory }, async (server) => { + const response = await fetch(`${server.url}/db-url`) + const body = await response.text() + console.log(body) + + t.expect(response.status).toBe(200) + t.expect(body).toEqual(JSON.stringify({ url: '' })) + }) + }) + }) +}) diff --git a/tests/integration/utils/site-builder.ts b/tests/integration/utils/site-builder.ts index a8cca356e01..2c81e52ecd6 100644 --- a/tests/integration/utils/site-builder.ts +++ b/tests/integration/utils/site-builder.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto' import { copyFile, mkdir, rm, unlink, writeFile } from 'fs/promises' import os from 'os' import path from 'path' @@ -317,13 +318,29 @@ export class SiteBuilder { } } +// Windows has a MAX_PATH limit of 260 characters. Since test directories +// include the temp dir, process version, PID, a UUID, and the site name, +// long test names can push nested file paths over this limit. We cap the +// site name and append a hash to avoid collisions. +const MAX_SITE_NAME_LENGTH = 50 + +const truncateSiteName = (siteName: string): string => { + if (siteName.length <= MAX_SITE_NAME_LENGTH) { + return siteName + } + + const hash = createHash('sha256').update(siteName).digest('hex').slice(0, 8) + + return `${siteName.slice(0, MAX_SITE_NAME_LENGTH - 9)}-${hash}` +} + export const createSiteBuilder = ({ siteName }: { siteName: string }) => { const directory = path.join( tempDirectory, `netlify-cli-tests-${process.version}`, `${process.pid}`, uuidv4(), - siteName, + truncateSiteName(siteName), ) return new SiteBuilder(directory).ensureDirectoryExists(directory) From ac45c4dc556a5843321230bd82f4485d36d3fa09 Mon Sep 17 00:00:00 2001 From: Sarah Etter Date: Thu, 19 Feb 2026 12:33:25 -0500 Subject: [PATCH 5/8] feat!: remove `sites:create-template` command (#7946) * feat: deprecate sites:create-template * chore: format * chore: lint * docs: build docs * fix: claude says this is how we fix the PR title lint issue * fix: lol now claude says this will work * remove this * remove unneeded issuePrefixes that claude added * fix: remove a bit more dead code * fix: remove sites:create-template placeholder cmd --------- Co-authored-by: Philippe Serhal --- commitlint.config.js | 11 +- docs/commands/sites.md | 35 --- docs/index.md | 1 - src/commands/sites/sites-create-template.ts | 294 ------------------ src/commands/sites/sites.ts | 26 -- src/utils/command-helpers.ts | 23 -- src/utils/sites/create-template.ts | 72 ----- src/utils/sites/utils.ts | 79 ----- src/utils/types.ts | 14 - .../sites/sites-create-template.test.ts | 249 --------------- .../integration/commands/sites/sites.test.ts | 128 +------- 11 files changed, 11 insertions(+), 921 deletions(-) delete mode 100644 src/commands/sites/sites-create-template.ts delete mode 100644 src/utils/sites/create-template.ts delete mode 100644 src/utils/sites/utils.ts delete mode 100644 tests/integration/commands/sites/sites-create-template.test.ts diff --git a/commitlint.config.js b/commitlint.config.js index 7c4ff4d9841..ce5afa95b4b 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1 +1,10 @@ -export default { extends: ['@commitlint/config-conventional'] } +export default { + extends: ['@commitlint/config-conventional'], + parserPreset: { + parserOpts: { + headerPattern: /^(\w+)(?:\(([^)]*)\))?(!)?:\s(.+)$/, + breakingHeaderPattern: /^(\w+)(?:\(([^)]*)\))?(!)?:\s(.+)$/, + headerCorrespondence: ['type', 'scope', 'breaking', 'subject'], + }, + }, +} diff --git a/docs/commands/sites.md b/docs/commands/sites.md index d11c84a135c..94ef29bcad2 100644 --- a/docs/commands/sites.md +++ b/docs/commands/sites.md @@ -26,7 +26,6 @@ netlify sites | Subcommand | description | |:--------------------------- |:-----| | [`sites:create`](/commands/sites#sitescreate) | Create an empty project (advanced) | -| [`sites:create-template`](/commands/sites#sitescreate-template) | (Beta) Create a project from a starter template | | [`sites:delete`](/commands/sites#sitesdelete) | Delete a project | | [`sites:list`](/commands/sites#siteslist) | List all projects you have access to | @@ -61,40 +60,6 @@ netlify sites:create - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `with-ci` (*boolean*) - initialize CI hooks during project creation ---- -## `sites:create-template` - -(Beta) Create a project from a starter template -Create a project from a starter template. - -**Usage** - -```bash -netlify sites:create-template -``` - -**Arguments** - -- repository - repository to use as starter template - -**Flags** - -- `account-slug` (*string*) - account slug to create the project under -- `filter` (*string*) - For monorepos, specify the name of the application to run the command in -- `name` (*string*) - name of project -- `url` (*string*) - template url -- `debug` (*boolean*) - Print debugging information -- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `with-ci` (*boolean*) - initialize CI hooks during project creation - -**Examples** - -```bash -netlify sites:create-template -netlify sites:create-template nextjs-blog-theme -netlify sites:create-template my-github-profile/my-template -``` - --- ## `sites:delete` diff --git a/docs/index.md b/docs/index.md index ee86844d08a..a306d1b873d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -165,7 +165,6 @@ Handle various project operations | Subcommand | description | |:--------------------------- |:-----| | [`sites:create`](/commands/sites#sitescreate) | Create an empty project (advanced) | -| [`sites:create-template`](/commands/sites#sitescreate-template) | (Beta) Create a project from a starter template | | [`sites:delete`](/commands/sites#sitesdelete) | Delete a project | | [`sites:list`](/commands/sites#siteslist) | List all projects you have access to | diff --git a/src/commands/sites/sites-create-template.ts b/src/commands/sites/sites-create-template.ts deleted file mode 100644 index 87057e9ea19..00000000000 --- a/src/commands/sites/sites-create-template.ts +++ /dev/null @@ -1,294 +0,0 @@ -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import type { OptionValues } from 'commander' -import inquirer from 'inquirer' -import pick from 'lodash/pick.js' -import { render } from 'prettyjson' -import { v4 as uuid } from 'uuid' - -import { - chalk, - logAndThrowError, - getTerminalLink, - log, - logJson, - warn, - type APIError, - GitHubAPIError, - type GitHubRepoResponse, -} from '../../utils/command-helpers.js' -import execa from '../../utils/execa.js' -import getRepoData from '../../utils/get-repo-data.js' -import { getGitHubToken } from '../../utils/init/config-github.js' -import { configureRepo } from '../../utils/init/config.js' -import { deployedSiteExists, getGitHubLink, getTemplateName } from '../../utils/sites/create-template.js' -import { callLinkSite, createRepo, validateTemplate } from '../../utils/sites/utils.js' -import { track } from '../../utils/telemetry/index.js' -import type { SiteInfo } from '../../utils/types.js' -import type BaseCommand from '../base-command.js' - -import { getSiteNameInput } from './sites-create.js' - -export const sitesCreateTemplate = async (repository: string, options: OptionValues, command: BaseCommand) => { - const { accounts, api } = command.netlify - await command.authenticate() - - const { globalConfig } = command.netlify - const ghToken = await getGitHubToken({ globalConfig }) - const templateName = await getTemplateName({ ghToken, options, repository }) - const { exists, isTemplate } = await validateTemplate({ templateName, ghToken }) - if (!exists) { - const githubLink = getGitHubLink({ options, templateName }) - return logAndThrowError( - `Could not find template ${chalk.bold(templateName)}. Please verify it exists and you can ${getTerminalLink( - 'access to it on GitHub', - githubLink, - )}`, - ) - } - if (!isTemplate) { - const githubLink = getGitHubLink({ options, templateName }) - return logAndThrowError(`${getTerminalLink(chalk.bold(templateName), githubLink)} is not a valid GitHub template`) - } - - let { accountSlug } = options - - if (!accountSlug) { - const { accountSlug: accountSlugInput } = await inquirer.prompt([ - { - type: 'list', - name: 'accountSlug', - message: 'Team:', - choices: accounts.map((account) => ({ - value: account.slug, - name: account.name, - })), - }, - ]) - accountSlug = accountSlugInput - } - - const { name: nameFlag } = options - let site: SiteInfo - let repoResp: Awaited> - - // Allow the user to reenter project name if selected one isn't available - const inputSiteName = async (name?: string, hasExistingRepo?: boolean): Promise<[SiteInfo, GitHubRepoResponse]> => { - const { name: inputName } = await getSiteNameInput(name) - - const siteName = inputName.trim() - - if (siteName && (await deployedSiteExists(siteName))) { - log('A project with that name already exists') - return inputSiteName() - } - - try { - const sites = await api.listSites({ name: siteName, filter: 'all' }) - const siteFoundByName = sites.find((filteredSite) => filteredSite.name === siteName) - if (siteFoundByName) { - log('A project with that name already exists on your account') - return inputSiteName() - } - } catch (error_) { - return logAndThrowError(error_) - } - - if (!hasExistingRepo) { - try { - // Create new repo from template - let gitHubInputName = siteName || templateName - repoResp = await createRepo(templateName, ghToken, gitHubInputName) - if (repoResp.errors && repoResp.errors[0].includes('Name already exists on this account')) { - if (gitHubInputName === templateName) { - gitHubInputName += `-${uuid().split('-')[0]}` - repoResp = await createRepo(templateName, ghToken, gitHubInputName) - } else { - warn(`It seems you have already created a repository with the name ${gitHubInputName}.`) - return inputSiteName() - } - } - if (!repoResp.id) { - throw new GitHubAPIError((repoResp as GitHubAPIError).status, (repoResp as GitHubAPIError).message) - } - hasExistingRepo = true - } catch (error_) { - if ((error_ as GitHubAPIError).status === '404') { - return logAndThrowError( - `Could not create repository: ${ - (error_ as GitHubAPIError).message - }. Ensure that your GitHub personal access token grants permission to create repositories`, - ) - } else { - return logAndThrowError( - `Something went wrong trying to create the repository. We're getting the following error: '${ - (error_ as GitHubAPIError).message - }'. You can try to re-run this command again or open an issue in our repository: https://github.com/netlify/cli/issues`, - ) - } - } - } - - try { - // FIXME(serhalp): `id` and `name` should be required in `netlify` package type - site = (await api.createSiteInTeam({ - accountSlug, - body: { - repo: { - provider: 'github', - // @ts-expect-error -- FIXME(serhalp): Supposedly this is does not exist. Investigate. - repo: repoResp.full_name, - // FIXME(serhalp): Supposedly this should be `public_repo`. Investigate. - private: repoResp.private, - // FIXME(serhalp): Supposedly this should be `repo_branch`. Investigate. - branch: repoResp.default_branch, - }, - name: siteName, - }, - })) as unknown as SiteInfo - } catch (error_) { - if ((error_ as APIError).status === 422) { - log(`createSiteInTeam error: ${(error_ as APIError).status}: ${(error_ as APIError).message}`) - log('Cannot create a project with that name. Project name may already exist. Please try a new name.') - return inputSiteName(undefined, hasExistingRepo) - } - return logAndThrowError(`createSiteInTeam error: ${(error_ as APIError).status}: ${(error_ as APIError).message}`) - } - return [site, repoResp] - } - - ;[site, repoResp] = await inputSiteName(nameFlag) - - log() - log(chalk.greenBright.bold.underline(`Project Created`)) - log() - - const siteUrl = site.ssl_url || site.url - log( - render({ - 'Admin URL': site.admin_url, - URL: siteUrl, - 'Project ID': site.id, - 'Repo URL': site.build_settings?.repo_url ?? '', - }), - ) - - track('sites_createdFromTemplate', { - siteId: site.id, - adminUrl: site.admin_url, - siteUrl, - }) - - const { cloneConfirm } = await inquirer.prompt({ - type: 'confirm', - name: 'cloneConfirm', - message: `Do you want to clone the repository to your local machine?`, - default: true, - }) - if (cloneConfirm) { - log() - - if (repoResp.clone_url) { - await execa('git', ['clone', repoResp.clone_url, `${repoResp.name}`]) - } - - log(`🚀 Repository cloned successfully. You can find it under the ${chalk.magenta(repoResp.name)} folder`) - - const { linkConfirm } = await inquirer.prompt({ - type: 'confirm', - name: 'linkConfirm', - message: `Do you want to link the cloned directory to the project?`, - default: true, - }) - - if (linkConfirm) { - const __dirname = path.dirname(fileURLToPath(import.meta.url)) - - const cliPath = path.resolve(__dirname, '../../../bin/run.js') - - let stdout - // TODO(serhalp): Why is this condition here? We've asked the user multiple prompts, but we already knew we had - // invalid repo data. Move upstream. - if (repoResp.name) { - stdout = await callLinkSite(cliPath, repoResp.name, '\n') - } else { - return logAndThrowError('Failed to fetch the repo') - } - - const linkedSiteUrlRegex = /Project url:\s+(\S+)/ - const lineMatch = linkedSiteUrlRegex.exec(stdout) - const urlMatch = lineMatch ? lineMatch[1] : undefined - if (urlMatch) { - log(`\nDirectory ${chalk.cyanBright(repoResp.name)} linked to project ${chalk.cyanBright(urlMatch)}\n`) - log( - `${chalk.cyanBright.bold('cd', repoResp.name)} to use other netlify cli commands in the cloned directory.\n`, - ) - } else { - const linkedSiteMatch = /Project already linked to\s+(\S+)/.exec(stdout) - const linkedSiteNameMatch = linkedSiteMatch ? linkedSiteMatch[1] : undefined - if (linkedSiteNameMatch) { - log(`\nThis directory appears to be linked to ${chalk.cyanBright(linkedSiteNameMatch)}`) - log('This can happen if you cloned the template into a subdirectory of an existing Netlify project.') - log( - `You may need to move the ${chalk.cyanBright( - repoResp.name, - )} directory out of its parent directory and then re-run the ${chalk.cyanBright( - 'link', - )} command manually\n`, - ) - } else { - log('A problem occurred linking the project') - log('You can try again manually by running:') - log(chalk.cyanBright(`cd ${repoResp.name} && netlify link\n`)) - } - } - } else { - log('To link the cloned directory manually, run:') - log(chalk.cyanBright(`cd ${repoResp.name} && netlify link\n`)) - } - } - - if (options.withCi) { - log('Configuring CI') - const repoData = await getRepoData({ workingDir: command.workingDir }) - - if ('error' in repoData) { - return logAndThrowError('Failed to get repo data') - } - - await configureRepo({ command, siteId: site.id, repoData, manual: options.manual }) - } - - if (options.json) { - logJson( - pick(site, [ - 'id', - 'state', - 'plan', - 'name', - 'custom_domain', - 'domain_aliases', - 'url', - 'ssl_url', - 'admin_url', - 'screenshot_url', - 'created_at', - 'updated_at', - 'user_id', - 'ssl', - 'force_ssl', - 'managed_dns', - 'deploy_url', - 'account_name', - 'account_slug', - 'git_provider', - 'deploy_hook', - 'capabilities', - 'id_domain', - ]), - ) - } - - return site -} diff --git a/src/commands/sites/sites.ts b/src/commands/sites/sites.ts index bf6913cfc3c..6b61bc6cf6a 100644 --- a/src/commands/sites/sites.ts +++ b/src/commands/sites/sites.ts @@ -1,5 +1,4 @@ import { OptionValues, InvalidArgumentError } from 'commander' - import BaseCommand from '../base-command.js' const MAX_SITE_NAME_LENGTH = 63 @@ -18,30 +17,6 @@ const sites = (_options: OptionValues, command: BaseCommand) => { command.help() } -export const createSitesFromTemplateCommand = (program: BaseCommand) => { - program - .command('sites:create-template') - .description( - `(Beta) Create a project from a starter template -Create a project from a starter template.`, - ) - .option('-n, --name [name]', 'name of project') - .option('-u, --url [url]', 'template url') - .option('-a, --account-slug [slug]', 'account slug to create the project under') - .option('-c, --with-ci', 'initialize CI hooks during project creation') - .argument('[repository]', 'repository to use as starter template') - .addHelpText('after', `(Beta) Create a project from starter template.`) - .addExamples([ - 'netlify sites:create-template', - 'netlify sites:create-template nextjs-blog-theme', - 'netlify sites:create-template my-github-profile/my-template', - ]) - .action(async (repository: string, options: OptionValues, command: BaseCommand) => { - const { sitesCreateTemplate } = await import('./sites-create-template.js') - await sitesCreateTemplate(repository, options, command) - }) -} - export const createSitesCreateCommand = (program: BaseCommand) => { program .command('sites:create') @@ -66,7 +41,6 @@ Create a blank project that isn't associated with any git remote. Will link the export const createSitesCommand = (program: BaseCommand) => { createSitesCreateCommand(program) - createSitesFromTemplateCommand(program) program .command('sites:list') diff --git a/src/utils/command-helpers.ts b/src/utils/command-helpers.ts index 6397cddaf86..73807a0fccc 100644 --- a/src/utils/command-helpers.ts +++ b/src/utils/command-helpers.ts @@ -241,29 +241,6 @@ export interface APIError extends Error { message: string } -export class GitHubAPIError extends Error { - status: string - - constructor(status: string, message: string) { - super(message) - this.status = status - this.name = 'GitHubAPIError' - } -} - -export interface GitHubRepoResponse { - status?: string - message?: string - id?: number - name?: string - clone_url?: string - full_name?: string - private?: boolean - default_branch?: string - errors?: string[] - is_template?: boolean -} - export const checkFileForLine = (filename: string, line: string) => { let filecontent = '' try { diff --git a/src/utils/sites/create-template.ts b/src/utils/sites/create-template.ts deleted file mode 100644 index bb8cc98bae1..00000000000 --- a/src/utils/sites/create-template.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { OptionValues } from 'commander' -import inquirer from 'inquirer' -import parseGitHubUrl from 'parse-github-url' - -import { log } from '../command-helpers.js' -import { Template, GitHubRepo } from '../types.js' - -import { getTemplatesFromGitHub } from './utils.js' - -export const fetchTemplates = async (token: string): Promise => { - const templatesFromGitHubOrg: GitHubRepo[] = await getTemplatesFromGitHub(token) - - return ( - templatesFromGitHubOrg - // adding this filter because the react-based-templates has multiple templates in one repo so doesn't work for this command - .filter((repo: GitHubRepo) => !repo.archived && !repo.disabled && repo.name !== 'react-based-templates') - .map((template: GitHubRepo) => ({ - name: template.name, - sourceCodeUrl: template.html_url, - slug: template.full_name, - })) - ) -} - -export const getTemplateName = async ({ - ghToken, - options, - repository, -}: { - ghToken: string - options: OptionValues - repository: string -}) => { - if (repository) { - const parsedUrl = parseGitHubUrl(repository) - return parsedUrl?.repo || `netlify-templates/${repository}` - } - - if (options.url) { - const urlFromOptions = new URL(options.url) - return urlFromOptions.pathname.slice(1) - } - - const templates = await fetchTemplates(ghToken) - - log(`Choose one of our starter templates. Netlify will create a new repo for this template in your GitHub account.`) - - const { templateName } = await inquirer.prompt([ - { - type: 'list', - name: 'templateName', - message: 'Template:', - choices: templates.map((template) => ({ - value: template.slug, - name: template.name, - })), - }, - ]) - - return templateName -} - -export const deployedSiteExists = async (name: string): Promise => { - const resp = await fetch(`https://${name}.netlify.app`, { - method: 'GET', - }) - - return resp.status === 200 -} - -export const getGitHubLink = ({ options, templateName }: { options: OptionValues; templateName: string }): string => - (options.url as string | undefined) || `https://github.com/${templateName}` diff --git a/src/utils/sites/utils.ts b/src/utils/sites/utils.ts deleted file mode 100644 index b60111a4b8a..00000000000 --- a/src/utils/sites/utils.ts +++ /dev/null @@ -1,79 +0,0 @@ -import fetch from 'node-fetch' -import execa from 'execa' - -import { GitHubRepoResponse, logAndThrowError } from '../command-helpers.js' -import { GitHubRepo } from '../types.js' - -export const getTemplatesFromGitHub = async (token: string): Promise => { - const getPublicGitHubReposFromOrg = new URL(`https://api.github.com/orgs/netlify-templates/repos`) - // GitHub returns 30 by default and we want to avoid our limit - // due to our archived repositories at any given time - const REPOS_PER_PAGE = 70 - - getPublicGitHubReposFromOrg.searchParams.set('type', 'public') - getPublicGitHubReposFromOrg.searchParams.set('sort', 'full_name') - // @ts-expect-error TS(2345) FIXME: Argument of type 'number' is not assignable to par... Remove this comment to see the full error message - getPublicGitHubReposFromOrg.searchParams.set('per_page', REPOS_PER_PAGE) - - let allTemplates: GitHubRepo[] = [] - try { - const templates = await fetch(getPublicGitHubReposFromOrg, { - method: 'GET', - headers: { - Authorization: `token ${token}`, - }, - }) - allTemplates = (await templates.json()) as GitHubRepo[] - } catch (error_) { - return logAndThrowError(error_) - } - return allTemplates -} - -export const validateTemplate = async ({ ghToken, templateName }: { ghToken: string; templateName: string }) => { - const response = await fetch(`https://api.github.com/repos/${templateName}`, { - headers: { - Authorization: `token ${ghToken}`, - }, - }) - - if (response.status === 404) { - return { exists: false } - } - - if (!response.ok) { - throw new Error(`Error fetching template ${templateName}: ${await response.text()}`) - } - - const data = (await response.json()) as GitHubRepoResponse - - return { exists: true, isTemplate: data.is_template } -} - -export const createRepo = async ( - templateName: string, - ghToken: string, - siteName: string, -): Promise => { - const resp = await fetch(`https://api.github.com/repos/${templateName}/generate`, { - method: 'POST', - headers: { - Authorization: `token ${ghToken}`, - }, - body: JSON.stringify({ - name: siteName, - }), - }) - - const data = await resp.json() - - return data as GitHubRepoResponse -} - -export const callLinkSite = async (cliPath: string, repoName: string, input: string) => { - const { stdout } = await execa(cliPath, ['link'], { - input, - cwd: repoName, - }) - return stdout -} diff --git a/src/utils/types.ts b/src/utils/types.ts index f2c33e4b541..ec299154b6f 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -204,20 +204,6 @@ export type MinimalAccount = { members_count: number } -export interface GitHubRepo { - name: string - html_url: string - full_name: string - archived: boolean - disabled: boolean -} - -export interface Template { - name: string - sourceCodeUrl: string - slug: string -} - type EnvironmentVariableScope = 'builds' | 'functions' | 'runtime' | 'post_processing' export type EnvironmentVariableSource = 'account' | 'addons' | 'configFile' | 'general' | 'internal' | 'ui' diff --git a/tests/integration/commands/sites/sites-create-template.test.ts b/tests/integration/commands/sites/sites-create-template.test.ts deleted file mode 100644 index ccbc2105a34..00000000000 --- a/tests/integration/commands/sites/sites-create-template.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import process from 'process' - -import inquirer from 'inquirer' -import { beforeEach, afterEach, describe, expect, test, vi, afterAll } from 'vitest' - -import BaseCommand from '../../../../src/commands/base-command.js' -import { createSitesFromTemplateCommand } from '../../../../src/commands/sites/sites.js' -import { deployedSiteExists, fetchTemplates, getTemplateName } from '../../../../src/utils/sites/create-template.js' -import { - getTemplatesFromGitHub, - validateTemplate, - createRepo, - callLinkSite, -} from '../../../../src/utils/sites/utils.js' -import { getEnvironmentVariables, withMockApi } from '../../utils/mock-api.js' -import { chalk } from '../../../../src/utils/command-helpers.js' - -vi.mock('../../../../src/utils/init/config-github.ts') -vi.mock('../../../../src/utils/sites/utils.ts') -vi.mock('../../../../src/utils/sites/create-template.ts') -vi.mock('inquirer') - -inquirer.registerPrompt = vi.fn() -inquirer.prompt.registerPrompt = vi.fn() - -const siteInfo = { - admin_url: 'https://app.netlify.com/projects/site-name/overview', - ssl_url: 'https://site-name.netlify.app/', - id: 'site_id', - name: 'site-name', - build_settings: { repo_url: 'https://github.com/owner/repo' }, -} - -const routes = [ - { - path: 'accounts', - response: [{ slug: 'test-account' }], - }, - { - path: 'sites', - response: [{ name: 'test-name' }], - }, - { - path: 'test-account/sites', - response: siteInfo, - method: 'POST' as const, - }, -] - -const OLD_ENV = process.env - -describe('sites:create-template', () => { - beforeEach(async () => { - inquirer.prompt = Object.assign( - vi - .fn() - .mockImplementationOnce(() => Promise.resolve({ accountSlug: 'test-account' })) - .mockImplementationOnce(() => Promise.resolve({ name: 'test-name' })) - .mockImplementationOnce(() => Promise.resolve({ cloneConfirm: true })) - .mockImplementationOnce(() => Promise.resolve({ linkConfirm: true })), - { - prompts: inquirer.prompt?.prompts || {}, - registerPrompt: inquirer.prompt?.registerPrompt || vi.fn(), - restoreDefaultPrompts: inquirer.prompt?.restoreDefaultPrompts || vi.fn(), - }, - ) - - vi.mocked(fetchTemplates).mockResolvedValue([ - { - name: 'mockTemplateName', - sourceCodeUrl: 'mockUrl', - slug: 'mockSlug', - }, - ]) - vi.mocked(getTemplatesFromGitHub).mockResolvedValue([ - { - name: 'mock-name', - html_url: 'mock-url', - full_name: 'mock-full-name', - archived: false, - disabled: false, - }, - ]) - vi.mocked(getTemplateName).mockResolvedValue('mockTemplateName') - vi.mocked(validateTemplate).mockResolvedValue({ - exists: true, - isTemplate: true, - }) - vi.mocked(createRepo).mockResolvedValue({ - id: 1, - full_name: 'mockName', - private: true, - default_branch: 'mockBranch', - name: 'repoName', - }) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - afterAll(() => { - vi.resetModules() - vi.restoreAllMocks() - - Object.defineProperty(process, 'env', { - value: OLD_ENV, - }) - }) - - test('it should ask for a new project name if project with that name already exists on a globally deployed project', async (t) => { - const stdoutwriteSpy = vi.spyOn(process.stdout, 'write') - await withMockApi(routes, async ({ apiUrl }) => { - Object.assign(process.env, getEnvironmentVariables({ apiUrl })) - - const program = new BaseCommand('netlify') - - vi.mocked(deployedSiteExists).mockResolvedValue(true) - - createSitesFromTemplateCommand(program) - - await program.parseAsync([ - '', - '', - 'sites:create-template', - '--account-slug', - 'test-account', - '--name', - 'test-name', - ]) - }) - expect(stdoutwriteSpy).toHaveBeenCalledWith('A project with that name already exists\n') - }) - - test('it should ask for a new project name if project with that name already exists on account', async (t) => { - const stdoutwriteSpy = vi.spyOn(process.stdout, 'write') - await withMockApi(routes, async ({ apiUrl }) => { - Object.assign(process.env, getEnvironmentVariables({ apiUrl })) - - const program = new BaseCommand('netlify') - - vi.mocked(deployedSiteExists).mockResolvedValue(false) - - createSitesFromTemplateCommand(program) - - await program.parseAsync([ - '', - '', - 'sites:create-template', - '--account-slug', - 'test-account', - '--name', - 'test-name', - ]) - }) - expect(stdoutwriteSpy).toHaveBeenCalledWith('A project with that name already exists on your account\n') - }) - - test('it should automatically link to the project when the user clones the template repo', async (t) => { - const mockSuccessfulLinkOutput = ` - Directory Linked - - Admin url: https://app.netlify.com/projects/site-name - Project url: https://site-name.netlify.app - - You can now run other \`netlify\` cli commands in this directory - ` - vi.mocked(callLinkSite).mockImplementationOnce(() => Promise.resolve(mockSuccessfulLinkOutput)) - - const autoLinkRoutes = [ - { - path: 'accounts', - response: [{ slug: 'test-account' }], - }, - { - path: 'sites', - response: [{ name: 'test-name-unique' }], - }, - { - path: 'test-account/sites', - response: siteInfo, - method: 'POST' as const, - }, - ] - - const stdoutwriteSpy = vi.spyOn(process.stdout, 'write') - await withMockApi(autoLinkRoutes, async ({ apiUrl }) => { - Object.assign(process.env, getEnvironmentVariables({ apiUrl })) - - const program = new BaseCommand('netlify') - - vi.mocked(deployedSiteExists).mockResolvedValue(false) - - createSitesFromTemplateCommand(program) - - await program.parseAsync(['', '', 'sites:create-template']) - }) - - expect(stdoutwriteSpy).toHaveBeenCalledWith( - `\nDirectory ${chalk.cyanBright('repoName')} linked to project ${chalk.cyanBright( - 'https://site-name.netlify.app', - )}\n\n`, - ) - }) - - test('it should output instructions if a project is already linked', async (t) => { - const mockUnsuccessfulLinkOutput = ` - Project already linked to \"site-name\" - Admin url: https://app.netlify.com/projects/site-name - - To unlink this project, run: netlify unlink - ` - - vi.mocked(callLinkSite).mockImplementationOnce(() => Promise.resolve(mockUnsuccessfulLinkOutput)) - - const autoLinkRoutes = [ - { - path: 'accounts', - response: [{ slug: 'test-account' }], - }, - { - path: 'sites', - response: [{ name: 'test-name-unique' }], - }, - { - path: 'test-account/sites', - response: siteInfo, - method: 'POST' as const, - }, - ] - - const stdoutwriteSpy = vi.spyOn(process.stdout, 'write') - await withMockApi(autoLinkRoutes, async ({ apiUrl }) => { - Object.assign(process.env, getEnvironmentVariables({ apiUrl })) - - const program = new BaseCommand('netlify') - - vi.mocked(deployedSiteExists).mockResolvedValue(false) - - createSitesFromTemplateCommand(program) - - await program.parseAsync(['', '', 'sites:create-template']) - }) - - expect(stdoutwriteSpy).toHaveBeenCalledWith( - `\nThis directory appears to be linked to ${chalk.cyanBright(`"site-name"`)}\n`, - ) - }) -}) diff --git a/tests/integration/commands/sites/sites.test.ts b/tests/integration/commands/sites/sites.test.ts index 4afd03e174a..f3cd152b500 100644 --- a/tests/integration/commands/sites/sites.test.ts +++ b/tests/integration/commands/sites/sites.test.ts @@ -1,14 +1,10 @@ import process from 'process' import inquirer from 'inquirer' -import { render } from 'prettyjson' import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest' import BaseCommand from '../../../../src/commands/base-command.js' -import { createSitesCreateCommand, createSitesFromTemplateCommand } from '../../../../src/commands/sites/sites.js' -import { getGitHubToken } from '../../../../src/utils/init/config-github.js' -import { fetchTemplates } from '../../../../src/utils/sites/create-template.js' -import { createRepo, getTemplatesFromGitHub } from '../../../../src/utils/sites/utils.js' +import { createSitesCreateCommand } from '../../../../src/commands/sites/sites.js' import { getEnvironmentVariables, withMockApi } from '../../utils/mock-api.js' vi.mock('../../../../src/utils/command-helpers.js', async () => ({ @@ -16,47 +12,6 @@ vi.mock('../../../../src/utils/command-helpers.js', async () => ({ log: () => {}, })) -// mock the getGithubToken method with a fake token -vi.mock('../../../../src/utils/init/config-github.js', () => ({ - getGitHubToken: vi.fn().mockImplementation(() => 'my-token'), -})) - -vi.mock('../../../../src/utils/sites/utils.js', () => ({ - getTemplatesFromGitHub: vi.fn().mockImplementation(() => [ - { - name: 'next-starter', - html_url: 'http://github.com/netlify-templates/next-starter', - full_name: 'netlify-templates/next-starter', - }, - { - name: 'archived-starter', - html_url: 'https://github.com/netlify-templates/fake-repo', - full_name: 'netlify-templates/fake-repo', - archived: true, - }, - ]), - createRepo: vi.fn().mockImplementation(() => ({ - full_name: 'Next starter', - private: false, - branch: 'main', - id: 1, - })), - validateTemplate: vi.fn().mockImplementation(() => ({ - exists: true, - isTemplate: true, - })), -})) - -vi.mock('prettyjson', async () => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const realRender = (await vi.importActual('prettyjson')) as typeof import('prettyjson') - - return { - ...realRender, - render: vi.fn().mockImplementation((...args: Parameters) => realRender.render(...args)), - } -}) - vi.spyOn(inquirer, 'prompt').mockImplementation(() => Promise.resolve({ accountSlug: 'test-account' })) const siteInfo = { @@ -107,87 +62,6 @@ describe('sites command', () => { value: OLD_ENV, }) }) - describe('sites:create-template', () => { - test('basic', async () => { - await withMockApi(routes, async ({ apiUrl }) => { - Object.assign(process.env, getEnvironmentVariables({ apiUrl })) - - const program = new BaseCommand('netlify') - - createSitesFromTemplateCommand(program) - - await program.parseAsync(['', '', 'sites:create-template']) - }) - - expect(getGitHubToken).toHaveBeenCalledOnce() - expect(getTemplatesFromGitHub).toHaveBeenCalledOnce() - expect(createRepo).toHaveBeenCalledOnce() - expect(render).toHaveBeenCalledOnce() - expect(render).toHaveBeenCalledWith({ - 'Admin URL': siteInfo.admin_url, - URL: siteInfo.ssl_url, - 'Project ID': siteInfo.id, - 'Repo URL': siteInfo.build_settings.repo_url, - }) - }) - - test('should not fetch templates if one is passed as option', async () => { - await withMockApi(routes, async ({ apiUrl }) => { - Object.assign(process.env, getEnvironmentVariables({ apiUrl })) - - const program = new BaseCommand('netlify') - - createSitesFromTemplateCommand(program) - - await program.parseAsync([ - '', - '', - 'sites:create-template', - '-u', - 'http://github.com/netlify-templates/next-starter', - ]) - - expect(getTemplatesFromGitHub).not.toHaveBeenCalled() - }) - }) - - test('should throw an error if the URL option is not a valid URL', async () => { - await withMockApi(routes, async ({ apiUrl }) => { - Object.assign(process.env, getEnvironmentVariables({ apiUrl })) - - const program = new BaseCommand('netlify') - - createSitesFromTemplateCommand(program) - - await expect(async () => { - await program.parseAsync(['', '', 'sites:create-template', '-u', 'not-a-url']) - }).rejects.toThrowError('Invalid URL') - }) - }) - }) - - describe('fetchTemplates', () => { - test('should return an array of templates with name, source code url and slug', async () => { - await withMockApi(routes, async ({ apiUrl }) => { - Object.assign(process.env, getEnvironmentVariables({ apiUrl })) - - const program = new BaseCommand('netlify') - - createSitesFromTemplateCommand(program) - - const templates = await fetchTemplates('fake-token') - - expect(getTemplatesFromGitHub).toHaveBeenCalledWith('fake-token') - expect(templates).toEqual([ - { - name: 'next-starter', - sourceCodeUrl: 'http://github.com/netlify-templates/next-starter', - slug: 'netlify-templates/next-starter', - }, - ]) - }) - }) - }) describe('sites:create', () => { test('should throw error when name flag is incorrect', async () => { From 94c0146d2ed8b44e654e9b69285b1d424fa2ff67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Sat, 21 Feb 2026 11:44:57 +0000 Subject: [PATCH 6/8] chore(ci): fix flakey integration tests (#7958) --- .github/workflows/conventional-commit.yml | 7 +- .github/workflows/integration-tests.yml | 55 ++++ e2e/install.e2e.ts | 9 +- .../commands/deploy/deploy.test.ts | 276 ++++++++---------- .../utils/create-live-test-site.ts | 9 +- 5 files changed, 197 insertions(+), 159 deletions(-) diff --git a/.github/workflows/conventional-commit.yml b/.github/workflows/conventional-commit.yml index c38a6479cfc..4f9ce1a1b95 100644 --- a/.github/workflows/conventional-commit.yml +++ b/.github/workflows/conventional-commit.yml @@ -7,7 +7,6 @@ jobs: lint-title: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - name: Install Dependencies - run: npm install @commitlint/config-conventional - - uses: JulienKode/pull-request-name-linter-action@v19.0.0 + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 9cb43bbc6eb..cf1cacb8405 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -11,8 +11,43 @@ on: - '!release-please--**' jobs: + setup: + name: Create test site + runs-on: ubuntu-latest + # Skip for fork PRs since they don't have access to secrets + if: ${{ !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) }} + timeout-minutes: 5 + outputs: + site-id: ${{ steps.create-site.outputs.site-id }} + site-name: ${{ steps.create-site.outputs.site-name }} + steps: + - name: Create site + id: create-site + run: | + SITE_NAME="netlify-test-deploy-$(openssl rand -hex 4)" + echo "Creating test site: $SITE_NAME" + HTTP_CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \ + -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"$SITE_NAME\"}" \ + "https://api.netlify.com/api/v1/netlify-integration-testing/sites") + if [ "$HTTP_CODE" -ne 201 ]; then + echo "::warning::Failed to create site. HTTP $HTTP_CODE. Live tests will be skipped." + exit 0 + fi + SITE_ID=$(jq -r '.id' response.json) + echo "site-id=$SITE_ID" >> "$GITHUB_OUTPUT" + echo "site-name=$SITE_NAME" >> "$GITHUB_OUTPUT" + echo "Created site $SITE_NAME with ID $SITE_ID" + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + integration: name: Integration + needs: [setup] + # Run even if setup was skipped (fork PRs) or failed to create a site. + # Tests that need a live site will be skipped if NETLIFY_LIVE_TEST_SITE_ID is not set. + if: ${{ !cancelled() }} runs-on: ${{ matrix.os }} timeout-minutes: 40 strategy: @@ -71,6 +106,9 @@ jobs: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }} NETLIFY_TEST_ACCOUNT_SLUG: 'netlify-integration-testing' NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + # Site created by the setup job, shared across all matrix jobs + NETLIFY_LIVE_TEST_SITE_ID: ${{ needs.setup.outputs.site-id }} + NETLIFY_LIVE_TEST_SITE_NAME: ${{ needs.setup.outputs.site-name }} # NETLIFY_TEST_GITHUB_TOKEN is used to avoid reaching GitHub API limits in exec-fetcher.js NETLIFY_TEST_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Changes the polling interval used by the file watcher @@ -106,3 +144,20 @@ jobs: with: flags: ${{ steps.test-coverage-flags.outputs.os }},${{ steps.test-coverage-flags.outputs.node }} token: ${{ secrets.CODECOV_TOKEN }} + + cleanup: + name: Delete test site + needs: [setup, integration] + if: ${{ always() && needs.setup.outputs.site-id }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Delete site + run: | + echo "Deleting test site ${{ needs.setup.outputs.site-id }}" + curl -sf -X DELETE \ + -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" \ + "https://api.netlify.com/api/v1/sites/${{ needs.setup.outputs.site-id }}" + echo "Deleted successfully" + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} diff --git a/e2e/install.e2e.ts b/e2e/install.e2e.ts index 2ea2bd978c8..1269455995a 100644 --- a/e2e/install.e2e.ts +++ b/e2e/install.e2e.ts @@ -69,6 +69,9 @@ const itWithMockNpmRegistry = it.extend<{ registry: { address: string; cwd: stri npmjs: { url: 'https://registry.npmjs.org/', maxage: '1d', + timeout: '60s', + max_fails: 5, + fail_timeout: '5m', cache: true, }, }, @@ -145,7 +148,7 @@ const itWithMockNpmRegistry = it.extend<{ registry: { address: string; cwd: stri }) } - await fs.rm(publishWorkspace, { force: true, recursive: true }) + await fs.rm(publishWorkspace, { force: true, recursive: true, maxRetries: 3, retryDelay: 1000 }) const testWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), tempdirPrefix)) await use({ @@ -159,8 +162,8 @@ const itWithMockNpmRegistry = it.extend<{ registry: { address: string; cwd: stri // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression server.closeAllConnections(), ]) - await fs.rm(testWorkspace, { force: true, recursive: true }) - await fs.rm(verdaccioStorageDir, { force: true, recursive: true }) + await fs.rm(testWorkspace, { force: true, recursive: true, maxRetries: 3, retryDelay: 1000 }) + await fs.rm(verdaccioStorageDir, { force: true, recursive: true, maxRetries: 3, retryDelay: 1000 }) }, }) diff --git a/tests/integration/commands/deploy/deploy.test.ts b/tests/integration/commands/deploy/deploy.test.ts index 87e8e22b026..373388ea6f0 100644 --- a/tests/integration/commands/deploy/deploy.test.ts +++ b/tests/integration/commands/deploy/deploy.test.ts @@ -29,18 +29,16 @@ const validateContent = async ({ pathname?: string | undefined siteUrl: string }) => { - const response = await fetch(`${siteUrl}${pathname}`, { headers }) + const url = `${siteUrl}${pathname}` + const response = await fetch(url, { headers }) const body = await response.text() + const requestId = response.headers.get('x-nf-request-id') ?? '' if (content === undefined) { expect(response.status).toBe(404) return } - expect(response.status, `status should be 200. request id: ${response.headers.get('x-nf-request-id') ?? ''}`).toBe( - 200, - ) - expect(body, `body should be as expected. request id: ${response.headers.get('x-nf-request-id') ?? ''}`).toEqual( - content, - ) + expect(response.status, `status should be 200. url: ${url} request id: ${requestId}`).toBe(200) + expect(body, `body should be as expected. url: ${url} request id: ${requestId}`).toEqual(content) } type Deploy = { @@ -57,6 +55,15 @@ type Deploy = { logs: string function_logs: string edge_function_logs: string + source_zip_filename?: string +} + +const parseDeploy = (output: string): Deploy => { + try { + return JSON.parse(output) + } catch { + throw new Error(`Failed to parse deploy output as JSON. Raw output:\n${output}`) + } } const validateDeploy = async ({ @@ -81,12 +88,15 @@ const validateDeploy = async ({ await validateContent({ siteUrl: deploy.deploy_url, path: '', content }) } -const context: { account: unknown; siteId: string } = { +const context: { account: unknown; siteId: string; siteName: string } = { siteId: '', + siteName: '', account: undefined, } -const disableLiveTests = process.env.NETLIFY_TEST_DISABLE_LIVE === 'true' +const disableLiveTests = + process.env.NETLIFY_TEST_DISABLE_LIVE === 'true' || + (process.env.CI === 'true' && !process.env.NETLIFY_LIVE_TEST_SITE_ID) // Running multiple entire build + deploy cycles concurrently results in a lot of network requests that may // cause resource contention anyway, so lower the default concurrency from 5 to 3. @@ -94,16 +104,26 @@ vi.setConfig({ maxConcurrency: 3 }) describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_000 }, () => { beforeAll(async () => { - const { account, siteId } = await createLiveTestSite(SITE_NAME) - context.siteId = siteId - context.account = account + // In CI, a shared site is created once by the setup job and passed via env var. + // Locally, we create (and later delete) a site per test run. + if (process.env.NETLIFY_LIVE_TEST_SITE_ID) { + context.siteId = process.env.NETLIFY_LIVE_TEST_SITE_ID + context.siteName = process.env.NETLIFY_LIVE_TEST_SITE_NAME ?? '' + } else { + const { account, siteId } = await createLiveTestSite(SITE_NAME) + context.siteId = siteId + context.siteName = SITE_NAME + context.account = account + } }) - afterAll(async () => { - const { siteId } = context - console.log(`deleting test site "${SITE_NAME}". ${siteId}`) - await callCli(['sites:delete', siteId, '--force']) - }) + if (!process.env.NETLIFY_LIVE_TEST_SITE_ID) { + afterAll(async () => { + const { siteId } = context + + await callCli(['sites:delete', siteId, '--force']) + }) + } test('should deploy project when dir flag is passed', async (t) => { await withSiteBuilder(t, async (builder) => { @@ -118,9 +138,9 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build', '--dir', 'public'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) + await validateDeploy({ deploy, siteName: context.siteName, content }) }) }) @@ -140,11 +160,11 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 await builder.build() - const deploy = await callCli(['deploy', '--json', '--no-build', '--site', SITE_NAME], { + const deploy = await callCli(['deploy', '--json', '--no-build', '--site', context.siteName], { cwd: builder.directory, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) + await validateDeploy({ deploy, siteName: context.siteName, content }) }) }) @@ -167,9 +187,9 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) + await validateDeploy({ deploy, siteName: context.siteName, content }) }) }) @@ -214,16 +234,14 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 if (shouldRunBuildBeforeDeploy) { await callCli(['build'], options) } - const deploy = await callCli(['deploy', '--json', '--no-build'], options).then((output: string) => - JSON.parse(output), - ) + const deploy = await callCli(['deploy', '--json', '--no-build'], options).then(parseDeploy) // give edge functions manifest a couple ticks to propagate await pause(500) await validateDeploy({ deploy, - siteName: SITE_NAME, + siteName: context.siteName, content: 'Edge Function works', contentMessage: 'Edge function did not execute correctly or was not deployed correctly', }) @@ -264,16 +282,14 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 if (shouldRunBuildBeforeDeploy) { await callCli(['build', '--cwd', pathPrefix], options) } - const deploy = await callCli(['deploy', '--json', '--no-build', '--cwd', pathPrefix], options).then( - (output: string) => JSON.parse(output), - ) + const deploy = await callCli(['deploy', '--json', '--no-build', '--cwd', pathPrefix], options).then(parseDeploy) // give edge functions manifest a couple ticks to propagate await pause(500) await validateDeploy({ deploy, - siteName: SITE_NAME, + siteName: context.siteName, content: 'Edge Function works', contentMessage: 'Edge function did not execute correctly or was not deployed correctly', }) @@ -312,16 +328,14 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 if (shouldRunBuildBeforeDeploy) { await callCli(['build'], options) } - const deploy = await callCli(['deploy', '--json', '--no-build'], options).then((output: string) => - JSON.parse(output), - ) + const deploy = await callCli(['deploy', '--json', '--no-build'], options).then(parseDeploy) // give edge functions manifest a couple ticks to propagate await pause(500) await validateDeploy({ deploy, - siteName: SITE_NAME, + siteName: context.siteName, content: 'Edge Function works', contentMessage: 'Edge function did not execute correctly or was not deployed correctly', }) @@ -359,16 +373,14 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 } // skipping running build here, because it cleans up frameworks API directories - const deploy = await callCli(['deploy', '--json', '--no-build'], options).then((output: string) => - JSON.parse(output), - ) + const deploy = await callCli(['deploy', '--json', '--no-build'], options).then(parseDeploy) // give edge functions manifest a couple ticks to propagate await pause(500) await validateDeploy({ deploy, - siteName: SITE_NAME, + siteName: context.siteName, content: 'Edge Function works', contentMessage: 'Edge function did not execute correctly or was not deployed correctly', }) @@ -567,17 +579,20 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build', '--dir', 'public'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) - expect(deploy).toHaveProperty('logs', `https://app.netlify.com/projects/${SITE_NAME}/deploys/${deploy.deploy_id}`) + await validateDeploy({ deploy, siteName: context.siteName, content }) + expect(deploy).toHaveProperty( + 'logs', + `https://app.netlify.com/projects/${context.siteName}/deploys/${deploy.deploy_id}`, + ) expect(deploy).toHaveProperty( 'function_logs', - `https://app.netlify.com/projects/${SITE_NAME}/logs/functions?scope=deploy:${deploy.deploy_id}`, + `https://app.netlify.com/projects/${context.siteName}/logs/functions?scope=deploy:${deploy.deploy_id}`, ) expect(deploy).toHaveProperty( 'edge_function_logs', - `https://app.netlify.com/projects/${SITE_NAME}/logs/edge-functions?scope=deployid:${deploy.deploy_id}`, + `https://app.netlify.com/projects/${context.siteName}/logs/edge-functions?scope=deployid:${deploy.deploy_id}`, ) }) }) @@ -594,14 +609,20 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build', '--dir', 'public', '--prod'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) - expect(deploy).toHaveProperty('logs', `https://app.netlify.com/projects/${SITE_NAME}/deploys/${deploy.deploy_id}`) - expect(deploy).toHaveProperty('function_logs', `https://app.netlify.com/projects/${SITE_NAME}/logs/functions`) + await validateDeploy({ deploy, siteName: context.siteName, content }) + expect(deploy).toHaveProperty( + 'logs', + `https://app.netlify.com/projects/${context.siteName}/deploys/${deploy.deploy_id}`, + ) + expect(deploy).toHaveProperty( + 'function_logs', + `https://app.netlify.com/projects/${context.siteName}/logs/functions`, + ) expect(deploy).toHaveProperty( 'edge_function_logs', - `https://app.netlify.com/projects/${SITE_NAME}/logs/edge-functions`, + `https://app.netlify.com/projects/${context.siteName}/logs/edge-functions`, ) }) }) @@ -720,9 +741,9 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content: 'index' }) + await validateDeploy({ deploy, siteName: context.siteName, content: 'index' }) await validateContent({ siteUrl: deploy.deploy_url, content: undefined, @@ -765,9 +786,9 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content: 'index' }) + await validateDeploy({ deploy, siteName: context.siteName, content: 'index' }) await validateContent({ siteUrl: deploy.deploy_url, content: undefined, @@ -800,9 +821,9 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content: 'index' }) + await validateDeploy({ deploy, siteName: context.siteName, content: 'index' }) await validateContent({ siteUrl: deploy.deploy_url, content: '{}', @@ -860,14 +881,10 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 }) .build() - const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json'], - { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as Deploy + const { deploy_url: deployUrl } = await callCli(['deploy', '--json'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) const response = await fetch(`${deployUrl}/.netlify/functions/hello`) t.expect(await response.text()).toEqual('Hello') @@ -970,14 +987,10 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 }) .build() - const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json'], - { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as Deploy + const { deploy_url: deployUrl } = await callCli(['deploy', '--json'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) // Add retry logic for fetching deployed functions const fetchWithRetry = async (url: string, maxRetries = 5) => { @@ -1031,14 +1044,10 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 }) .build() - const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json'], - { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as Deploy + const { deploy_url: deployUrl } = await callCli(['deploy', '--json'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) const response = await fetch(`${deployUrl}/.netlify/functions/func-1`).then((res) => res.text()) t.expect(response).toEqual('Internal') @@ -1089,29 +1098,24 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 }) .build() - const deploy = (await callCli( - ['deploy', '--json'], - { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as Deploy + const deploy = await callCli(['deploy', '--json'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) - const fullDeploy = (await callCli( + const fullDeploy = await callCli( ['api', 'getDeploy', '--data', JSON.stringify({ deploy_id: deploy.deploy_id })], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, }, - true, - )) as unknown as Deploy + ).then(parseDeploy) const redirectsMessage = fullDeploy.summary.messages.find(({ title }) => title === '3 redirect rules processed') t.expect(redirectsMessage).toBeDefined() t.expect(redirectsMessage!.description).toEqual('All redirect rules deployed without errors.') - await validateDeploy({ deploy, siteName: SITE_NAME, content }) + await validateDeploy({ deploy, siteName: context.siteName, content }) const [pluginRedirectResponse, _redirectsResponse, netlifyTomResponse] = await Promise.all([ fetch(`${deploy.deploy_url}/other-api/hello`).then((res) => res.text()), @@ -1174,14 +1178,10 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 }) .build() - const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json', '--no-build'], - { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as Deploy + const { deploy_url: deployUrl } = await callCli(['deploy', '--json', '--no-build'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) const response = await fetch(`${deployUrl}/.netlify/functions/bundled-function-1`).then((res) => res.text()) expect(response).toEqual('Pre-bundled') }) @@ -1233,14 +1233,10 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 }) .build() - const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json', '--no-build', '--skip-functions-cache'], - { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as Deploy + const { deploy_url: deployUrl } = await callCli(['deploy', '--json', '--no-build', '--skip-functions-cache'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) const response = await fetch(`${deployUrl}/.netlify/functions/bundled-function-1`).then((res) => res.text()) t.expect(response).toEqual('Bundled at deployment') @@ -1294,14 +1290,10 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 }) .build() - const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json', '--no-build'], - { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as { deploy_url: string } + const { deploy_url: deployUrl } = await callCli(['deploy', '--json', '--no-build'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) const response = await fetch(`${deployUrl}/.netlify/functions/bundled-function-1`).then((res) => res.text()) t.expect(response).toEqual('Bundled at deployment') @@ -1353,14 +1345,10 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 .build() await execa.command('npm install', { cwd: builder.directory }) - const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json', '--no-build'], - { - cwd: builder.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as { deploy_url: string } + const { deploy_url: deployUrl } = await callCli(['deploy', '--json', '--no-build'], { + cwd: builder.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) const response = await fetch(`${deployUrl}/read-blob`).then((res) => res.text()) t.expect(response).toEqual('hello from the blob') @@ -1374,14 +1362,10 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 timeout: 300_000, }, async ({ fixture }) => { - const { deploy_url: deployUrl } = (await callCli( - ['deploy', '--json'], - { - cwd: fixture.directory, - env: { NETLIFY_SITE_ID: context.siteId }, - }, - true, - )) as unknown as { deploy_url: string } + const { deploy_url: deployUrl } = await callCli(['deploy', '--json'], { + cwd: fixture.directory, + env: { NETLIFY_SITE_ID: context.siteId }, + }).then(parseDeploy) const html = await fetch(deployUrl).then((res) => res.text()) const $ = load(html) @@ -1422,16 +1406,16 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build', '--dir', 'public', '--draft'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) + await validateDeploy({ deploy, siteName: context.siteName, content }) expect(deploy).toHaveProperty( 'function_logs', - `https://app.netlify.com/projects/${SITE_NAME}/logs/functions?scope=deploy:${deploy.deploy_id}`, + `https://app.netlify.com/projects/${context.siteName}/logs/functions?scope=deploy:${deploy.deploy_id}`, ) expect(deploy).toHaveProperty( 'edge_function_logs', - `https://app.netlify.com/projects/${SITE_NAME}/logs/edge-functions?scope=deployid:${deploy.deploy_id}`, + `https://app.netlify.com/projects/${context.siteName}/logs/edge-functions?scope=deployid:${deploy.deploy_id}`, ) }) }) @@ -1486,16 +1470,16 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, }, - ).then((output: string) => JSON.parse(output)) + ).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) + await validateDeploy({ deploy, siteName: context.siteName, content }) expect(deploy).toHaveProperty( 'function_logs', - `https://app.netlify.com/projects/${SITE_NAME}/logs/functions?scope=deploy:${deploy.deploy_id}`, + `https://app.netlify.com/projects/${context.siteName}/logs/functions?scope=deploy:${deploy.deploy_id}`, ) expect(deploy).toHaveProperty( 'edge_function_logs', - `https://app.netlify.com/projects/${SITE_NAME}/logs/edge-functions?scope=deployid:${deploy.deploy_id}`, + `https://app.netlify.com/projects/${context.siteName}/logs/edge-functions?scope=deployid:${deploy.deploy_id}`, ) expect(deploy.deploy_url).toContain('test-branch--') }) @@ -1515,9 +1499,9 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--no-build', '--dir', 'public', '--upload-source-zip'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) + await validateDeploy({ deploy, siteName: context.siteName, content }) expect(deploy).toHaveProperty('source_zip_filename') expect(typeof deploy.source_zip_filename).toBe('string') expect(deploy.source_zip_filename).toMatch(/\.zip$/) @@ -1550,9 +1534,9 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 const deploy = await callCli(['deploy', '--json', '--dir', 'public', '--upload-source-zip'], { cwd: builder.directory, env: { NETLIFY_SITE_ID: context.siteId }, - }).then((output: string) => JSON.parse(output)) + }).then(parseDeploy) - await validateDeploy({ deploy, siteName: SITE_NAME, content }) + await validateDeploy({ deploy, siteName: context.siteName, content }) expect(deploy).toHaveProperty('source_zip_filename') expect(typeof deploy.source_zip_filename).toBe('string') expect(deploy.source_zip_filename).toMatch(/\.zip$/) @@ -1614,9 +1598,7 @@ describe.skipIf(disableLiveTests).concurrent('commands/deploy', { timeout: 300_0 } await callCli(['build'], options) - const deploy = (await callCli(['deploy', '--json', '--no-build'], options).then((output: string) => - JSON.parse(output), - )) as Deploy + const deploy = await callCli(['deploy', '--json', '--no-build'], options).then(parseDeploy) await pause(500) diff --git a/tests/integration/utils/create-live-test-site.ts b/tests/integration/utils/create-live-test-site.ts index 4bb58b24486..c1e92b9c588 100644 --- a/tests/integration/utils/create-live-test-site.ts +++ b/tests/integration/utils/create-live-test-site.ts @@ -18,8 +18,8 @@ const listAccounts = async () => { } export const createLiveTestSite = async function (siteName: string) { - console.log(`Creating new project for tests: ${siteName}`) const accounts = await listAccounts() + if (!Array.isArray(accounts) || accounts.length <= 0) { throw new Error(`Can't find suitable account to create a project`) } @@ -33,12 +33,12 @@ export const createLiveTestSite = async function (siteName: string) { ) } const accountSlug = account.slug - console.log(`Using account ${accountSlug} to create project: ${siteName}`) + const cliResponse = (await callCli(['sites:create', '--name', siteName, '--account-slug', accountSlug])) as string const isProjectCreated = cliResponse.includes('Project Created') if (!isProjectCreated) { - throw new Error(`Failed creating project: ${cliResponse}`) + throw new Error(`Failed creating project. CLI response:\n${cliResponse}`) } const { default: stripAnsi } = await import('strip-ansi') @@ -46,9 +46,8 @@ export const createLiveTestSite = async function (siteName: string) { const matches = /Project ID:\s+([a-zA-Z\d-]+)/m.exec(stripAnsi(cliResponse)) if (matches && Object.prototype.hasOwnProperty.call(matches, 1) && matches[1]) { const [, siteId] = matches - console.log(`Done creating project ${siteName} for account '${accountSlug}'. Project Id: ${siteId}`) return { siteId, account } } - throw new Error(`Failed creating project: ${cliResponse}`) + throw new Error(`Failed to extract project ID from CLI response:\n${cliResponse}`) } From 6adbd47301ad5328a2b5d0faacaab2e20d032e79 Mon Sep 17 00:00:00 2001 From: Sarah Etter Date: Mon, 23 Feb 2026 13:11:55 -0500 Subject: [PATCH 7/8] fix: remove dead path --- src/utils/init/config-manual.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/init/config-manual.ts b/src/utils/init/config-manual.ts index 90ff5477fee..366f2300c46 100644 --- a/src/utils/init/config-manual.ts +++ b/src/utils/init/config-manual.ts @@ -86,10 +86,10 @@ 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: repoData.provider ?? 'manual', - repo_path: repoData.repo ?? repoPath, + repo_path: repoPath, repo_branch: repoData.branch, allowed_branches: [repoData.branch], deploy_key_id: deployKey.id, From f849e35ffff8a4e611d3d9b3c5c3a7e2b308f47f Mon Sep 17 00:00:00 2001 From: Sarah Etter Date: Tue, 5 May 2026 15:34:21 -0400 Subject: [PATCH 8/8] test: address review feedback on get-repo-data and config-manual tests Rewrite get-repo-data tests to drive the real getRepoData function with mocked git deps so parsing and provider mapping are actually exercised, and fix the unsafe direct cast on netlify.config in the config-manual test by routing it through unknown. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/unit/utils/get-repo-data.test.ts | 219 +++++++++++--------- tests/unit/utils/init/config-manual.test.ts | 2 +- 2 files changed, 122 insertions(+), 99 deletions(-) diff --git a/tests/unit/utils/get-repo-data.test.ts b/tests/unit/utils/get-repo-data.test.ts index feaa1c8fb36..6433a1ffae1 100644 --- a/tests/unit/utils/get-repo-data.test.ts +++ b/tests/unit/utils/get-repo-data.test.ts @@ -1,120 +1,143 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RepoData } from '../../../src/utils/get-repo-data.js' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import getRepoData from '../../../src/utils/get-repo-data.js' + +const mockGitConfig = vi.fn() +const mockFindUp = vi.fn() +const mockGitRepoInfo = vi.fn() + +vi.mock('gitconfiglocal', () => ({ + default: (workingDir: string, cb: (err: Error | null, config: unknown) => void) => { + try { + cb(null, mockGitConfig(workingDir)) + } catch (err) { + cb(err as Error, null) + } + }, +})) + +vi.mock('find-up', () => ({ + findUp: (...args: unknown[]): unknown => mockFindUp(...args), +})) + +vi.mock('git-repo-info', () => ({ + default: (): unknown => mockGitRepoInfo(), +})) 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') + beforeEach(() => { + vi.clearAllMocks() + mockFindUp.mockResolvedValue('/test/.git') + mockGitRepoInfo.mockReturnValue({ branch: 'main' }) + }) + + it('parses GitHub SSH URLs', async () => { + mockGitConfig.mockReturnValue({ remote: { origin: { url: 'git@github.com:ownername/test.git' } } }) + + const result = await getRepoData({ workingDir: '/test' }) + + expect(result).toEqual({ + name: 'test', + owner: 'ownername', + repo: 'ownername/test', + url: 'git@github.com:ownername/test.git', + branch: 'main', + provider: 'github', + httpsUrl: 'https://github.com/ownername/test', }) + }) + + it('parses GitLab SSH URLs', async () => { + mockGitConfig.mockReturnValue({ remote: { origin: { url: 'git@gitlab.com:ownername/test.git' } } }) - 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') + const result = await getRepoData({ workingDir: '/test' }) + + expect(result).toEqual({ + name: 'test', + owner: 'ownername', + repo: 'ownername/test', + url: 'git@gitlab.com:ownername/test.git', + branch: 'main', + provider: 'gitlab', + httpsUrl: 'https://gitlab.com/ownername/test', }) + }) + + it('parses GitHub HTTPS URLs', async () => { + mockGitConfig.mockReturnValue({ remote: { origin: { url: 'https://github.com/ownername/test.git' } } }) + + const result = await getRepoData({ workingDir: '/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') + expect(result).toMatchObject({ + provider: 'github', + repo: 'ownername/test', + httpsUrl: 'https://github.com/ownername/test', }) + }) + + it('parses GitLab HTTPS URLs', async () => { + mockGitConfig.mockReturnValue({ remote: { origin: { url: 'https://gitlab.com/ownername/test.git' } } }) + + const result = await getRepoData({ workingDir: '/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') + expect(result).toMatchObject({ + provider: 'gitlab', + repo: 'ownername/test', + httpsUrl: 'https://gitlab.com/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') + it('uses host as provider for unknown Git hosts', async () => { + mockGitConfig.mockReturnValue({ + remote: { origin: { url: 'git@custom-git.example.com:user/test.git' } }, + }) + + const result = await getRepoData({ workingDir: '/test' }) + + expect(result).toMatchObject({ + provider: 'custom-git.example.com', + repo: 'user/test', + httpsUrl: 'https://custom-git.example.com/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('uses the specified remote name when provided', async () => { + mockGitConfig.mockReturnValue({ + remote: { + origin: { url: 'git@github.com:owner/origin-repo.git' }, + upstream: { url: 'git@gitlab.com:owner/upstream-repo.git' }, + }, }) - 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') + const result = await getRepoData({ workingDir: '/test', remoteName: 'upstream' }) + + expect(result).toMatchObject({ + provider: 'gitlab', + repo: 'owner/upstream-repo', + }) + }) + + it('returns an error when no Git remote is found', async () => { + mockFindUp.mockResolvedValue(undefined) + mockGitConfig.mockReturnValue({ remote: {} }) + + const result = await getRepoData({ workingDir: '/test' }) + + expect(result).toEqual({ error: 'No Git remote found' }) + }) + + it('returns an error when the requested remote is not defined', async () => { + mockGitConfig.mockReturnValue({ remote: { origin: { url: 'git@github.com:owner/repo.git' } } }) + + const result = await getRepoData({ workingDir: '/test', remoteName: 'missing' }) + + expect(result).toEqual({ + error: + 'The specified remote "missing" is not defined in Git repo. Please use --git-remote-name flag to specify a remote.', }) }) }) diff --git a/tests/unit/utils/init/config-manual.test.ts b/tests/unit/utils/init/config-manual.test.ts index 0cdc807d28f..0d2bd7edadc 100644 --- a/tests/unit/utils/init/config-manual.test.ts +++ b/tests/unit/utils/init/config-manual.test.ts @@ -41,7 +41,7 @@ describe('config-manual', () => { netlify: { api: mockApi as NetlifyAPI, cachedConfig: { configPath: '/test/netlify.toml' } as BaseCommand['netlify']['cachedConfig'], - config: { plugins: [] } as BaseCommand['netlify']['config'], + config: { plugins: [] } as unknown as BaseCommand['netlify']['config'], repositoryRoot: '/test', } as BaseCommand['netlify'], }