diff --git a/.changeset/quiet-eels-rest.md b/.changeset/quiet-eels-rest.md new file mode 100644 index 000000000000..674f75c154e6 --- /dev/null +++ b/.changeset/quiet-eels-rest.md @@ -0,0 +1,5 @@ +--- +"create-cloudflare": patch +--- + +feat: add experimental Next.js, with Workers assets, template diff --git a/.github/actions/run-c3-e2e/action.yml b/.github/actions/run-c3-e2e/action.yml index 39901629bdf3..fa421e0c18d9 100644 --- a/.github/actions/run-c3-e2e/action.yml +++ b/.github/actions/run-c3-e2e/action.yml @@ -10,6 +10,9 @@ inputs: quarantine: description: "Whether to run the tests in quarantine mode" required: false + experimental: + description: "Whether to run the tests in experimental mode" + required: false framework: description: "When specified, will only run tests for this framework" required: false @@ -39,14 +42,7 @@ runs: git config --global user.email wrangler@cloudflare.com git config --global user.name 'Wrangler automated PR updater' - - name: Build - shell: bash - run: pnpm run build - env: - NODE_ENV: "production" - CI_OS: ${{ runner.os }} - - - name: E2E Tests + - name: E2E Tests ${{inputs.experimental && '(experimental)' || ''}} id: run-e2e shell: bash run: pnpm run test:e2e --filter create-cloudflare @@ -54,6 +50,7 @@ runs: CLOUDFLARE_API_TOKEN: ${{ inputs.apiToken }} CLOUDFLARE_ACCOUNT_ID: ${{ inputs.accountId }} E2E_QUARANTINE: ${{ inputs.quarantine }} + E2E_EXPERIMENTAL: ${{ inputs.experimental }} FRAMEWORK_CLI_TO_TEST: ${{ inputs.framework }} TEST_PM: ${{ inputs.packageManager }} TEST_PM_VERSION: ${{ inputs.packageManagerVersion }} @@ -64,15 +61,15 @@ runs: uses: actions/upload-artifact@v3 if: always() with: - name: e2e-logs-${{matrix.os}} - path: packages/create-cloudflare/.e2e-logs + name: e2e-logs${{inputs.experimental && '-experimental' || ''}}-${{matrix.os}} + path: packages/create-cloudflare/.e2e-logs${{inputs.experimental && '-experimental' || ''}} - name: Upload Framework Diffs if: ${{ steps.run-e2e.outcome == 'success' && inputs.saveDiffs == 'true' }} uses: actions/upload-artifact@v3 with: - name: e2e-framework-diffs - path: packages/create-cloudflare/.e2e-diffs + name: e2e-framework-diffs${{inputs.experimental && '-experimental' || ''}} + path: packages/create-cloudflare/.e2e-diffs${{inputs.experimental && '-experimental' || ''}} overwrite: true - name: Fail if errors detected diff --git a/.github/workflows/c3-e2e-experimental.yml b/.github/workflows/c3-e2e-experimental.yml new file mode 100644 index 000000000000..4a43ed41f758 --- /dev/null +++ b/.github/workflows/c3-e2e-experimental.yml @@ -0,0 +1,64 @@ +# Runs c3 e2e tests on pull requests with c3 changes + +name: C3 E2E Tests (experimental) +on: + pull_request: + paths: + - packages/create-cloudflare/** + push: + branches: + - main + paths: + - packages/create-cloudflare/** + +jobs: + e2e: + # Note: please keep this job in sync with the e2e-only-dependabot-bumped-framework one + #  in .github/workflows/c3-e2e-dependabot.yml + timeout-minutes: 45 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.os }}-${{ matrix.pm.name }}-${{ matrix.pm.version }} + cancel-in-progress: true + name: ${{ format('Run experimental tests for {0}@{1} on {2}', matrix.pm.name, matrix.pm.version, matrix.os) }} + if: github.repository_owner == 'cloudflare' && github.event.pull_request.user.login != 'dependabot[bot]' + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + pm: + [ + { name: npm, version: "0.0.0" }, + { name: pnpm, version: "9.10.0" }, + { name: bun, version: "1.0.3" }, + { name: yarn, version: "1.0.0" }, + ] + # include a single windows test with pnpm + include: + - os: windows-latest + pm: { name: pnpm, version: "9.10.0" } + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Dependencies + uses: ./.github/actions/install-dependencies + with: + turbo-api: ${{ secrets.TURBO_API }} + turbo-team: ${{ secrets.TURBO_TEAM }} + turbo-token: ${{ secrets.TURBO_TOKEN }} + turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + + - name: E2E Tests + uses: ./.github/actions/run-c3-e2e + with: + packageManager: ${{ matrix.pm.name }} + packageManagerVersion: ${{ matrix.pm.version }} + quarantine: false + experimental: true + accountId: ${{ secrets.C3_TEST_CLOUDFLARE_ACCOUNT_ID }} + apiToken: ${{ secrets.C3_TEST_CLOUDFLARE_API_TOKEN }} + # We only need to do this once per-framework per-run, so avoid re-running for each package manager and os + saveDiffs: ${{ github.head_ref == 'changeset-release/main' && matrix.pm.name == 'pnpm' && matrix.os == 'ubuntu-latest'}} diff --git a/.prettierignore b/.prettierignore index 21ec43abe695..933b3b5e4736 100644 --- a/.prettierignore +++ b/.prettierignore @@ -31,7 +31,7 @@ dist-functions vscode.d.ts vscode.*.d.ts -.e2e-logs +.e2e-logs* templates/*/build templates/*/dist diff --git a/packages/create-cloudflare/.env.example b/packages/create-cloudflare/.env.example index 9e1b2466451e..2e5478ed5cbd 100644 --- a/packages/create-cloudflare/.env.example +++ b/packages/create-cloudflare/.env.example @@ -8,3 +8,4 @@ # E2E_RETRIES=0 # the number of retries for framework e2e tests # E2E_NO_DEPLOY=true # flag to skip the deployment step in the e2es (for easier debugging, where the deployment is not relevant to current changes) # SAVE_DIFFS=true # flag to trigger the diffs saving during the e2es process +# E2E_EXPERIMENTAL=true # flag to run only experimental framework e2e tests diff --git a/packages/create-cloudflare/.gitignore b/packages/create-cloudflare/.gitignore index d7d418bf4d9f..ae10202c35a5 100644 --- a/packages/create-cloudflare/.gitignore +++ b/packages/create-cloudflare/.gitignore @@ -1,8 +1,8 @@ node_modules /dist create-cloudflare-*.tgz -/.e2e-logs/* -/.e2e-diffs/* +/.e2e-logs*/* +/.e2e-diffs*/* .DS_Store diff --git a/packages/create-cloudflare/e2e-tests/cli.test.ts b/packages/create-cloudflare/e2e-tests/cli.test.ts index 31bd2ce2663c..5b4e25aa1945 100644 --- a/packages/create-cloudflare/e2e-tests/cli.test.ts +++ b/packages/create-cloudflare/e2e-tests/cli.test.ts @@ -21,11 +21,12 @@ import { import type { WriteStream } from "fs"; import type { Suite } from "vitest"; +const experimental = Boolean(process.env.E2E_EXPERIMENTAL); const frameworkToTest = getFrameworkToTest({ experimental: false }); // Note: skipIf(frameworkToTest) makes it so that all the basic C3 functionality // tests are skipped in case we are testing a specific framework -describe.skipIf(frameworkToTest || isQuarantineMode())( +describe.skipIf(experimental || frameworkToTest || isQuarantineMode())( "E2E: Basic C3 functionality ", () => { const tmpDirPath = realpathSync(mkdtempSync(join(tmpdir(), "c3-tests"))); @@ -33,12 +34,12 @@ describe.skipIf(frameworkToTest || isQuarantineMode())( let logStream: WriteStream; beforeAll((ctx) => { - recreateLogFolder(ctx as Suite); + recreateLogFolder({ experimental }, ctx as Suite); }); beforeEach((ctx) => { rmSync(projectPath, { recursive: true, force: true }); - logStream = createTestLogStream(ctx); + logStream = createTestLogStream({ experimental }, ctx); }); afterEach(() => { diff --git a/packages/create-cloudflare/e2e-tests/frameworks.test.ts b/packages/create-cloudflare/e2e-tests/frameworks.test.ts index 46c1e854dde7..43730ddbd2bf 100644 --- a/packages/create-cloudflare/e2e-tests/frameworks.test.ts +++ b/packages/create-cloudflare/e2e-tests/frameworks.test.ts @@ -1,3 +1,4 @@ +import assert from "assert"; import { existsSync } from "fs"; import { cp } from "fs/promises"; import { join } from "path"; @@ -31,7 +32,7 @@ import { testProjectDir, waitForExit, } from "./helpers"; -import type { TemplateMap } from "../src/templates"; +import type { TemplateConfig } from "../src/templates"; import type { RunnerConfig } from "./helpers"; import type { WriteStream } from "fs"; import type { Suite } from "vitest"; @@ -67,432 +68,470 @@ type FrameworkTestConfig = RunnerConfig & { const { name: pm, npx } = detectPackageManager(); -// These are ordered based on speed and reliability for ease of debugging -const frameworkTests: Record = { - astro: { - testCommitMessage: true, - quarantine: true, - unsupportedOSs: ["win32"], - verifyDeploy: { - route: "/", - expectedText: "Hello, Astronaut!", - }, - verifyDev: { - route: "/test", - expectedText: "C3_TEST", - }, - verifyBuild: { - outputDir: "./dist", - script: "build", - route: "/test", - expectedText: "C3_TEST", - }, - flags: [ - "--skip-houston", - "--no-install", - "--no-git", - "--template", - "blog", - "--typescript", - "strict", - ], - }, - docusaurus: { - unsupportedPms: ["bun"], - testCommitMessage: true, - unsupportedOSs: ["win32"], - timeout: LONG_TIMEOUT, - verifyDeploy: { - route: "/", - expectedText: "Dinosaurs are cool", - }, - flags: [`--package-manager`, pm], - promptHandlers: [ - { - matcher: /Which language do you want to use\?/, - input: [keys.enter], +function getFrameworkTests(opts: { + experimental: boolean; +}): Record { + if (opts.experimental) { + return { + next: { + testCommitMessage: false, + verifyBuildCfTypes: { + outputFile: "env.d.ts", + envInterfaceName: "CloudflareEnv", + }, + verifyDeploy: { + route: "/", + expectedText: "Create Next App", + }, + unsupportedOSs: ["win32"], }, - ], - }, - analog: { - testCommitMessage: true, - timeout: LONG_TIMEOUT, - unsupportedOSs: ["win32"], - // The analog template works with yarn, but the build takes so long that it - // becomes flaky in CI - unsupportedPms: ["yarn"], - verifyDeploy: { - route: "/", - expectedText: "The fullstack meta-framework for Angular!", - }, - verifyDev: { - route: "/api/v1/test", - expectedText: "C3_TEST", - }, - verifyBuildCfTypes: { - outputFile: "worker-configuration.d.ts", - envInterfaceName: "Env", - }, - verifyBuild: { - outputDir: "./dist/analog/public", - script: "build", - route: "/api/v1/test", - expectedText: "C3_TEST", - }, - flags: ["--skipTailwind"], - quarantine: true, - }, - angular: { - testCommitMessage: true, - timeout: LONG_TIMEOUT, - unsupportedOSs: ["win32"], - verifyDeploy: { - route: "/", - expectedText: "Congratulations! Your app is running.", - }, - flags: ["--style", "sass"], - }, - gatsby: { - unsupportedPms: ["bun", "pnpm"], - promptHandlers: [ - { - matcher: /Would you like to use a template\?/, - input: ["n"], + }; + } else { + // These are ordered based on speed and reliability for ease of debugging + return { + astro: { + testCommitMessage: true, + quarantine: true, + unsupportedOSs: ["win32"], + verifyDeploy: { + route: "/", + expectedText: "Hello, Astronaut!", + }, + verifyDev: { + route: "/test", + expectedText: "C3_TEST", + }, + verifyBuild: { + outputDir: "./dist", + script: "build", + route: "/test", + expectedText: "C3_TEST", + }, + flags: [ + "--skip-houston", + "--no-install", + "--no-git", + "--template", + "blog", + "--typescript", + "strict", + ], }, - ], - testCommitMessage: true, - timeout: LONG_TIMEOUT, - verifyDeploy: { - route: "/", - expectedText: "Gatsby!", - }, - }, - hono: { - testCommitMessage: false, - unsupportedOSs: ["win32"], - verifyDeploy: { - route: "/", - expectedText: "Hello Hono!", - }, - promptHandlers: [ - { - matcher: /Do you want to install project dependencies\?/, - input: [keys.enter], + docusaurus: { + unsupportedPms: ["bun"], + testCommitMessage: true, + unsupportedOSs: ["win32"], + timeout: LONG_TIMEOUT, + verifyDeploy: { + route: "/", + expectedText: "Dinosaurs are cool", + }, + flags: [`--package-manager`, pm], + promptHandlers: [ + { + matcher: /Which language do you want to use\?/, + input: [keys.enter], + }, + ], }, - ], - }, - qwik: { - promptHandlers: [ - { - matcher: /Yes looks good, finish update/, - input: [keys.enter], + analog: { + testCommitMessage: true, + timeout: LONG_TIMEOUT, + unsupportedOSs: ["win32"], + // The analog template works with yarn, but the build takes so long that it + // becomes flaky in CI + unsupportedPms: ["yarn"], + verifyDeploy: { + route: "/", + expectedText: "The fullstack meta-framework for Angular!", + }, + verifyDev: { + route: "/api/v1/test", + expectedText: "C3_TEST", + }, + verifyBuildCfTypes: { + outputFile: "worker-configuration.d.ts", + envInterfaceName: "Env", + }, + verifyBuild: { + outputDir: "./dist/analog/public", + script: "build", + route: "/api/v1/test", + expectedText: "C3_TEST", + }, + flags: ["--skipTailwind"], + quarantine: true, }, - ], - testCommitMessage: true, - unsupportedOSs: ["win32"], - unsupportedPms: ["yarn"], - verifyDeploy: { - route: "/", - expectedText: "Welcome to Qwik", - }, - verifyDev: { - route: "/test", - expectedText: "C3_TEST", - }, - verifyBuildCfTypes: { - outputFile: "worker-configuration.d.ts", - envInterfaceName: "Env", - }, - verifyBuild: { - outputDir: "./dist", - script: "build", - route: "/test", - expectedText: "C3_TEST", - }, - }, - remix: { - testCommitMessage: true, - timeout: LONG_TIMEOUT, - unsupportedPms: ["yarn"], - unsupportedOSs: ["win32"], - verifyDeploy: { - route: "/", - expectedText: "Welcome to Remix", - }, - verifyDev: { - route: "/test", - expectedText: "C3_TEST", - }, - verifyBuildCfTypes: { - outputFile: "worker-configuration.d.ts", - envInterfaceName: "Env", - }, - verifyBuild: { - outputDir: "./build/client", - script: "build", - route: "/test", - expectedText: "C3_TEST", - }, - flags: ["--typescript", "--no-install", "--no-git-init"], - }, - next: { - promptHandlers: [ - { - matcher: /Do you want to use the next-on-pages eslint-plugin\?/, - input: ["y"], + angular: { + testCommitMessage: true, + timeout: LONG_TIMEOUT, + unsupportedOSs: ["win32"], + verifyDeploy: { + route: "/", + expectedText: "Congratulations! Your app is running.", + }, + flags: ["--style", "sass"], }, - ], - testCommitMessage: true, - quarantine: true, - verifyBuildCfTypes: { - outputFile: "env.d.ts", - envInterfaceName: "CloudflareEnv", - }, - verifyDeploy: { - route: "/", - expectedText: "Create Next App", - }, - flags: [ - "--typescript", - "--no-install", - "--eslint", - "--tailwind", - "--src-dir", - "--app", - "--import-alias", - "@/*", - ], - }, - nuxt: { - testCommitMessage: true, - timeout: LONG_TIMEOUT, - unsupportedOSs: ["win32"], - verifyDeploy: { - route: "/", - expectedText: "Welcome to Nuxt!", - }, - verifyDev: { - route: "/test", - expectedText: "C3_TEST", - }, - verifyBuildCfTypes: { - outputFile: "worker-configuration.d.ts", - envInterfaceName: "Env", - }, - verifyBuild: { - outputDir: "./dist", - script: "build", - route: "/test", - expectedText: "C3_TEST", - }, - }, - react: { - promptHandlers: [ - { - matcher: /Select a variant:/, - input: [keys.enter], + gatsby: { + unsupportedPms: ["bun", "pnpm"], + promptHandlers: [ + { + matcher: /Would you like to use a template\?/, + input: ["n"], + }, + ], + testCommitMessage: true, + timeout: LONG_TIMEOUT, + verifyDeploy: { + route: "/", + expectedText: "Gatsby!", + }, }, - ], - testCommitMessage: true, - unsupportedOSs: ["win32"], - unsupportedPms: ["yarn"], - timeout: LONG_TIMEOUT, - verifyDeploy: { - route: "/", - expectedText: "Vite + React", - }, - }, - solid: { - promptHandlers: [ - { - matcher: /Which template would you like to use/, - input: [keys.enter], + hono: { + testCommitMessage: false, + unsupportedOSs: ["win32"], + verifyDeploy: { + route: "/", + expectedText: "Hello Hono!", + }, + promptHandlers: [ + { + matcher: /Do you want to install project dependencies\?/, + input: [keys.enter], + }, + ], }, - { - matcher: /Use Typescript/, - input: [keys.enter], + qwik: { + promptHandlers: [ + { + matcher: /Yes looks good, finish update/, + input: [keys.enter], + }, + ], + testCommitMessage: true, + unsupportedOSs: ["win32"], + unsupportedPms: ["yarn"], + verifyDeploy: { + route: "/", + expectedText: "Welcome to Qwik", + }, + verifyDev: { + route: "/test", + expectedText: "C3_TEST", + }, + verifyBuildCfTypes: { + outputFile: "worker-configuration.d.ts", + envInterfaceName: "Env", + }, + verifyBuild: { + outputDir: "./dist", + script: "build", + route: "/test", + expectedText: "C3_TEST", + }, }, - ], - testCommitMessage: true, - timeout: LONG_TIMEOUT, - unsupportedPms: ["npm", "yarn"], - unsupportedOSs: ["win32"], - verifyDeploy: { - route: "/", - expectedText: "Hello world", - }, - }, - svelte: { - promptHandlers: [ - { - matcher: /Which Svelte app template/, - input: [keys.enter], + remix: { + testCommitMessage: true, + timeout: LONG_TIMEOUT, + unsupportedPms: ["yarn"], + unsupportedOSs: ["win32"], + verifyDeploy: { + route: "/", + expectedText: "Welcome to Remix", + }, + verifyDev: { + route: "/test", + expectedText: "C3_TEST", + }, + verifyBuildCfTypes: { + outputFile: "worker-configuration.d.ts", + envInterfaceName: "Env", + }, + verifyBuild: { + outputDir: "./build/client", + script: "build", + route: "/test", + expectedText: "C3_TEST", + }, + flags: ["--typescript", "--no-install", "--no-git-init"], }, - { - matcher: /Add type checking with TypeScript/, - input: [keys.down, keys.enter], + next: { + promptHandlers: [ + { + matcher: /Do you want to use the next-on-pages eslint-plugin\?/, + input: ["y"], + }, + ], + testCommitMessage: true, + quarantine: true, + verifyBuildCfTypes: { + outputFile: "env.d.ts", + envInterfaceName: "CloudflareEnv", + }, + verifyDeploy: { + route: "/", + expectedText: "Create Next App", + }, + flags: [ + "--typescript", + "--no-install", + "--eslint", + "--tailwind", + "--src-dir", + "--app", + "--import-alias", + "@/*", + ], }, - { - matcher: /Select additional options/, - input: [keys.enter], + nuxt: { + testCommitMessage: true, + timeout: LONG_TIMEOUT, + unsupportedOSs: ["win32"], + verifyDeploy: { + route: "/", + expectedText: "Welcome to Nuxt!", + }, + verifyDev: { + route: "/test", + expectedText: "C3_TEST", + }, + verifyBuildCfTypes: { + outputFile: "worker-configuration.d.ts", + envInterfaceName: "Env", + }, + verifyBuild: { + outputDir: "./dist", + script: "build", + route: "/test", + expectedText: "C3_TEST", + }, }, - ], - testCommitMessage: true, - unsupportedOSs: ["win32"], - unsupportedPms: ["npm"], - verifyDeploy: { - route: "/", - expectedText: "SvelteKit app", - }, - verifyDev: { - route: "/test", - expectedText: "C3_TEST", - }, - verifyBuild: { - outputDir: ".svelte-kit/cloudflare", - script: "build", - route: "/test", - expectedText: "C3_TEST", - }, - }, - vue: { - testCommitMessage: true, - unsupportedOSs: ["win32"], - verifyDeploy: { - route: "/", - expectedText: "Vite App", - }, - flags: ["--ts"], - quarantine: true, - }, -}; - -describe.concurrent(`E2E: Web frameworks`, () => { - let frameworkMap: TemplateMap; - let logStream: WriteStream; - - beforeAll(async (ctx) => { - frameworkMap = getFrameworkMap({ experimental: false }); - recreateLogFolder(ctx as Suite); - recreateDiffsFolder(); - }); - - beforeEach(async (ctx) => { - logStream = createTestLogStream(ctx); - }); - - afterEach(async () => { - logStream.close(); - }); - - Object.keys(frameworkTests).forEach((framework) => { - const { quarantine, timeout, unsupportedPms, unsupportedOSs } = - frameworkTests[framework]; - - const quarantineModeMatch = isQuarantineMode() == (quarantine ?? false); - - // If the framework in question is being run in isolation, always run it. - // Otherwise, only run the test if it's configured `quarantine` value matches - // what is set in E2E_QUARANTINE - const frameworkToTest = getFrameworkToTest({ experimental: false }); - let shouldRun = frameworkToTest - ? frameworkToTest === framework - : quarantineModeMatch; - - // Skip if the package manager is unsupported - shouldRun &&= !unsupportedPms?.includes(TEST_PM); - - // Skip if the OS is unsupported - shouldRun &&= !unsupportedOSs?.includes(process.platform); - test.runIf(shouldRun)( - framework, - async () => { - const { getPath, getName, clean } = testProjectDir("pages"); - const projectPath = getPath(framework); - const projectName = getName(framework); - const frameworkConfig = frameworkMap[framework]; - - const { promptHandlers, verifyDeploy, flags } = - frameworkTests[framework]; - - if (!verifyDeploy) { - expect( - true, - "A `deploy` configuration must be defined for all framework tests", - ).toBe(false); - return; - } - - try { - const deploymentUrl = await runCli( - framework, - projectPath, - logStream, - { - argv: [...(flags ? ["--", ...flags] : [])], - promptHandlers, - }, - ); - - // Relevant project files should have been created - expect(projectPath).toExist(); - const pkgJsonPath = join(projectPath, "package.json"); - expect(pkgJsonPath).toExist(); - - // Wrangler should be installed - const wranglerPath = join(projectPath, "node_modules/wrangler"); - expect(wranglerPath).toExist(); - - // Make a request to the deployed project and verify it was successful - await verifyDeployment( - framework, - projectName, - `${deploymentUrl}${verifyDeploy.route}`, - verifyDeploy.expectedText, - ); - - // Copy over any test fixture files - const fixturePath = join(__dirname, "fixtures", framework); - if (existsSync(fixturePath)) { - await cp(fixturePath, projectPath, { - recursive: true, - force: true, - }); + react: { + promptHandlers: [ + { + matcher: /Select a variant:/, + input: [keys.enter], + }, + ], + testCommitMessage: true, + unsupportedOSs: ["win32"], + unsupportedPms: ["yarn"], + timeout: LONG_TIMEOUT, + verifyDeploy: { + route: "/", + expectedText: "Vite + React", + }, + }, + solid: { + promptHandlers: [ + { + matcher: /Which template would you like to use/, + input: [keys.enter], + }, + { + matcher: /Use Typescript/, + input: [keys.enter], + }, + ], + testCommitMessage: true, + timeout: LONG_TIMEOUT, + unsupportedPms: ["npm", "yarn"], + unsupportedOSs: ["win32"], + verifyDeploy: { + route: "/", + expectedText: "Hello world", + }, + }, + svelte: { + promptHandlers: [ + { + matcher: /Which Svelte app template/, + input: [keys.enter], + }, + { + matcher: /Add type checking with TypeScript/, + input: [keys.down, keys.enter], + }, + { + matcher: /Select additional options/, + input: [keys.enter], + }, + ], + testCommitMessage: true, + unsupportedOSs: ["win32"], + unsupportedPms: ["npm"], + verifyDeploy: { + route: "/", + expectedText: "SvelteKit app", + }, + verifyDev: { + route: "/test", + expectedText: "C3_TEST", + }, + verifyBuild: { + outputDir: ".svelte-kit/cloudflare", + script: "build", + route: "/test", + expectedText: "C3_TEST", + }, + }, + vue: { + testCommitMessage: true, + unsupportedOSs: ["win32"], + verifyDeploy: { + route: "/", + expectedText: "Vite App", + }, + flags: ["--ts"], + quarantine: true, + }, + }; + } +} + +const experimental = Boolean(process.env.E2E_EXPERIMENTAL); +const frameworkMap = getFrameworkMap({ experimental }); +const frameworkTests = getFrameworkTests({ experimental }); + +describe.concurrent( + `E2E: Web frameworks (experimental:${experimental})`, + () => { + let logStream: WriteStream; + + beforeAll(async (ctx) => { + recreateLogFolder({ experimental }, ctx as Suite); + recreateDiffsFolder({ experimental }); + }); + + beforeEach(async (ctx) => { + logStream = createTestLogStream({ experimental }, ctx); + }); + + afterEach(async () => { + logStream.close(); + }); + + test("dummy in case there are no frameworks to test", () => {}); + + Object.keys(frameworkTests).forEach((frameworkId) => { + const frameworkConfig = frameworkMap[frameworkId]; + const testConfig = frameworkTests[frameworkId]; + + const quarantineModeMatch = + isQuarantineMode() == (testConfig.quarantine ?? false); + + // If the framework in question is being run in isolation, always run it. + // Otherwise, only run the test if it's configured `quarantine` value matches + // what is set in E2E_QUARANTINE + const frameworkToTest = getFrameworkToTest({ experimental }); + let shouldRun = frameworkToTest + ? frameworkToTest === frameworkId + : quarantineModeMatch; + + // Skip if the package manager is unsupported + shouldRun &&= !testConfig.unsupportedPms?.includes(TEST_PM); + + // Skip if the OS is unsupported + shouldRun &&= !testConfig.unsupportedOSs?.includes(process.platform); + test.runIf(shouldRun)( + frameworkId, + async () => { + const { getPath, getName, clean } = testProjectDir("pages"); + const projectPath = getPath(frameworkId); + const projectName = getName(frameworkId); + + if (!testConfig.verifyDeploy) { + expect( + true, + "A `deploy` configuration must be defined for all framework tests", + ).toBe(false); + return; } - await verifyDevScript(framework, projectPath, logStream); - await verifyBuildCfTypesScript(framework, projectPath, logStream); - await verifyBuildScript(framework, projectPath, logStream); - await storeDiff(framework, projectPath); - } catch (e) { - console.error("ERROR", e); - expect.fail( - "Failed due to an exception while running C3. See logs for more details", - ); - } finally { - clean(framework); - // Cleanup the project in case we need to retry it - if (frameworkConfig.platform === "workers") { - await deleteWorker(projectName); - } else { - await deleteProject(projectName); + try { + const deploymentUrl = await runCli( + frameworkId, + projectPath, + logStream, + { + argv: [ + ...(experimental ? ["--experimental"] : []), + ...(testConfig.flags ? ["--", ...testConfig.flags] : []), + ], + promptHandlers: testConfig.promptHandlers, + }, + ); + + // Relevant project files should have been created + expect(projectPath).toExist(); + const pkgJsonPath = join(projectPath, "package.json"); + expect(pkgJsonPath).toExist(); + + // Wrangler should be installed + const wranglerPath = join(projectPath, "node_modules/wrangler"); + expect(wranglerPath).toExist(); + + // Make a request to the deployed project and verify it was successful + await verifyDeployment( + testConfig, + frameworkId, + projectName, + `${deploymentUrl}${testConfig.verifyDeploy.route}`, + testConfig.verifyDeploy.expectedText, + ); + + // Copy over any test fixture files + const fixturePath = join(__dirname, "fixtures", frameworkId); + if (existsSync(fixturePath)) { + await cp(fixturePath, projectPath, { + recursive: true, + force: true, + }); + } + + await verifyDevScript( + testConfig, + frameworkConfig, + projectPath, + logStream, + ); + await verifyBuildCfTypesScript(testConfig, projectPath, logStream); + await verifyBuildScript(testConfig, projectPath, logStream); + await storeDiff(frameworkId, projectPath, { experimental }); + } catch (e) { + console.error("ERROR", e); + expect.fail( + "Failed due to an exception while running C3. See logs for more details", + ); + } finally { + clean(frameworkId); + // Cleanup the project in case we need to retry it + if (frameworkConfig.platform === "workers") { + await deleteWorker(projectName); + } else { + await deleteProject(projectName); + } } - } - }, - { - retry: TEST_RETRIES, - timeout: timeout || TEST_TIMEOUT, - }, - ); - }); -}); + }, + { + retry: TEST_RETRIES, + timeout: testConfig.timeout || TEST_TIMEOUT, + }, + ); + }); + }, +); -const storeDiff = async (framework: string, projectPath: string) => { +const storeDiff = async ( + framework: string, + projectPath: string, + opts: { experimental: boolean }, +) => { if (!process.env.SAVE_DIFFS) { return; } - const outputPath = join(getDiffsPath(), `${framework}.diff`); + const outputPath = join(getDiffsPath(opts), `${framework}.diff`); const output = await runCommand(["git", "diff"], { silent: true, @@ -531,6 +570,7 @@ const runCli = async ( const match = output.replaceAll("\n", "").match(deployedUrlRe); if (!match || !match[1]) { + console.error(output); expect(false, "Couldn't find deployment url in C3 output").toBe(true); return ""; } @@ -539,7 +579,8 @@ const runCli = async ( }; const verifyDeployment = async ( - framework: string, + { testCommitMessage }: FrameworkTestConfig, + frameworkId: string, projectName: string, deploymentUrl: string, expectedText: string, @@ -548,10 +589,8 @@ const verifyDeployment = async ( return; } - const { testCommitMessage } = frameworkTests[framework]; - if (testCommitMessage) { - await testDeploymentCommitMessage(projectName, framework); + await testDeploymentCommitMessage(projectName, frameworkId); } await retry({ times: 5 }, async () => { @@ -567,17 +606,16 @@ const verifyDeployment = async ( }; const verifyDevScript = async ( - framework: string, + { verifyDev }: FrameworkTestConfig, + { devScript }: TemplateConfig, projectPath: string, logStream: WriteStream, ) => { - const { verifyDev } = frameworkTests[framework]; if (!verifyDev) { return; } - const frameworkMap = getFrameworkMap({ experimental: false }); - const template = frameworkMap[framework]; + assert(devScript !== undefined, "Expected `devScript` to be defined"); // Run the devserver on a random port to avoid colliding with other tests const TEST_PORT = Math.ceil(Math.random() * 1000) + 20000; @@ -586,7 +624,7 @@ const verifyDevScript = async ( [ pm, "run", - template.devScript as string, + devScript, ...(pm === "npm" ? ["--"] : []), "--port", `${TEST_PORT}`, @@ -626,12 +664,10 @@ const verifyDevScript = async ( }; const verifyBuildCfTypesScript = async ( - framework: string, + { verifyBuildCfTypes }: FrameworkTestConfig, projectPath: string, logStream: WriteStream, ) => { - const { verifyBuildCfTypes } = frameworkTests[framework]; - if (!verifyBuildCfTypes) { return; } @@ -670,12 +706,10 @@ const verifyBuildCfTypesScript = async ( }; const verifyBuildScript = async ( - framework: string, + { verifyBuild }: FrameworkTestConfig, projectPath: string, logStream: WriteStream, ) => { - const { verifyBuild } = frameworkTests[framework]; - if (!verifyBuild) { return; } diff --git a/packages/create-cloudflare/e2e-tests/helpers.ts b/packages/create-cloudflare/e2e-tests/helpers.ts index c608f02ce9ce..f91737a11faa 100644 --- a/packages/create-cloudflare/e2e-tests/helpers.ts +++ b/packages/create-cloudflare/e2e-tests/helpers.ts @@ -282,18 +282,24 @@ export const waitForExit = async ( }; }; -export const createTestLogStream = (ctx: TaskContext) => { +export const createTestLogStream = ( + opts: { experimental: boolean }, + ctx: TaskContext, +) => { // The .ansi extension allows for editor extensions that format ansi terminal codes const fileName = `${normalizeTestName(ctx)}.ansi`; assert(ctx.task.suite, "Suite must be defined"); - return createWriteStream(path.join(getLogPath(ctx.task.suite), fileName), { - flags: "a", - }); + return createWriteStream( + path.join(getLogPath(opts, ctx.task.suite), fileName), + { + flags: "a", + }, + ); }; -export const recreateDiffsFolder = () => { +export const recreateDiffsFolder = (opts: { experimental: boolean }) => { // Recreate the diffs folder - const diffsPath = getDiffsPath(); + const diffsPath = getDiffsPath(opts); rmSync(diffsPath, { recursive: true, force: true, @@ -301,21 +307,26 @@ export const recreateDiffsFolder = () => { mkdirSync(diffsPath, { recursive: true }); }; -export const getDiffsPath = () => { - return path.resolve("./.e2e-diffs"); +export const getDiffsPath = (opts: { experimental: boolean }) => { + return path.resolve( + "./.e2e-diffs" + (opts.experimental ? "-experimental" : ""), + ); }; -export const recreateLogFolder = (suite: Suite) => { +export const recreateLogFolder = ( + opts: { experimental: boolean }, + suite: Suite, +) => { // Clean the old folder if exists (useful for dev) - rmSync(getLogPath(suite), { + rmSync(getLogPath(opts, suite), { recursive: true, force: true, }); - mkdirSync(getLogPath(suite), { recursive: true }); + mkdirSync(getLogPath(opts, suite), { recursive: true }); }; -const getLogPath = (suite: Suite) => { +const getLogPath = (opts: { experimental: boolean }, suite: Suite) => { const { file } = suite; const suiteFilename = file @@ -323,7 +334,7 @@ const getLogPath = (suite: Suite) => { : "unknown"; return path.join( - "./.e2e-logs/", + "./.e2e-logs" + (opts.experimental ? "-experimental" : ""), process.env.TEST_PM as string, suiteFilename, ); diff --git a/packages/create-cloudflare/e2e-tests/workers.test.ts b/packages/create-cloudflare/e2e-tests/workers.test.ts index 62d180073966..d6259f37b7d9 100644 --- a/packages/create-cloudflare/e2e-tests/workers.test.ts +++ b/packages/create-cloudflare/e2e-tests/workers.test.ts @@ -25,46 +25,56 @@ type WorkerTestConfig = RunnerConfig & { variants: string[]; }; -const workerTemplates: WorkerTestConfig[] = [ - { - template: "hello-world", - variants: ["TypeScript", "JavaScript", "Python"], - verifyDeploy: { - route: "/", - expectedText: "Hello World!", - }, - }, - { - template: "common", - variants: ["TypeScript", "JavaScript"], - verifyDeploy: { - route: "/", - expectedText: "Try making requests to:", - }, - }, - { - template: "queues", - variants: ["TypeScript", "JavaScript"], - // Skipped for now, since C3 does not yet support resource creation - }, - { - template: "scheduled", - variants: ["TypeScript", "JavaScript"], - // Skipped for now, since it's not possible to test scheduled events on deployed Workers - }, - { - template: "openapi", - variants: [], - verifyDeploy: { - route: "/", - expectedText: "SwaggerUI", - }, - }, -]; +function getWorkerTests(opts: { experimental: boolean }): WorkerTestConfig[] { + if (opts.experimental) { + return []; + } else { + return [ + { + template: "hello-world", + variants: ["TypeScript", "JavaScript", "Python"], + verifyDeploy: { + route: "/", + expectedText: "Hello World!", + }, + }, + { + template: "common", + variants: ["TypeScript", "JavaScript"], + verifyDeploy: { + route: "/", + expectedText: "Try making requests to:", + }, + }, + { + template: "queues", + variants: ["TypeScript", "JavaScript"], + // Skipped for now, since C3 does not yet support resource creation + }, + { + template: "scheduled", + variants: ["TypeScript", "JavaScript"], + // Skipped for now, since it's not possible to test scheduled events on deployed Workers + }, + { + template: "openapi", + variants: [], + verifyDeploy: { + route: "/", + expectedText: "SwaggerUI", + }, + }, + ]; + } +} + +const experimental = Boolean(process.env.E2E_EXPERIMENTAL); +const workerTests = getWorkerTests({ experimental }); describe .skipIf( - getFrameworkToTest({ experimental: false }) || + experimental || // skip until we add tests for experimental workers templates + getFrameworkToTest({ experimental }) || isQuarantineMode() || process.platform === "win32", ) @@ -72,14 +82,14 @@ describe let logStream: WriteStream; beforeAll((ctx) => { - recreateLogFolder(ctx as Suite); + recreateLogFolder({ experimental }, ctx as Suite); }); beforeEach(async (ctx) => { - logStream = createTestLogStream(ctx); + logStream = createTestLogStream({ experimental }, ctx); }); - workerTemplates + workerTests .flatMap((template) => template.variants.length > 0 ? template.variants.map((variant) => { diff --git a/packages/create-cloudflare/src/templates.ts b/packages/create-cloudflare/src/templates.ts index a19b914c5731..5c73a4647883 100644 --- a/packages/create-cloudflare/src/templates.ts +++ b/packages/create-cloudflare/src/templates.ts @@ -26,6 +26,7 @@ import assetsOnlyTemplateExperimental from "templates-experimental/hello-world-a import helloWorldWithDurableObjectAssetsTemplateExperimental from "templates-experimental/hello-world-durable-object-with-assets/c3"; import helloWorldWithAssetsTemplateExperimental from "templates-experimental/hello-world-with-assets/c3"; import honoTemplateExperimental from "templates-experimental/hono/c3"; +import nextTemplateExperimental from "templates-experimental/next/c3"; import nuxtTemplateExperimental from "templates-experimental/nuxt/c3"; import qwikTemplateExperimental from "templates-experimental/qwik/c3"; import remixTemplateExperimental from "templates-experimental/remix/c3"; @@ -167,6 +168,7 @@ export function getFrameworkMap({ experimental = false }): TemplateMap { docusaurus: docusaurusTemplateExperimental, gatsby: gatsbyTemplateExperimental, hono: honoTemplateExperimental, + next: nextTemplateExperimental, nuxt: nuxtTemplateExperimental, qwik: qwikTemplateExperimental, remix: remixTemplateExperimental, diff --git a/packages/create-cloudflare/templates-experimental/next/c3.ts b/packages/create-cloudflare/templates-experimental/next/c3.ts new file mode 100644 index 000000000000..94e6274c00e8 --- /dev/null +++ b/packages/create-cloudflare/templates-experimental/next/c3.ts @@ -0,0 +1,52 @@ +import { brandColor, dim } from "@cloudflare/cli/colors"; +import { runFrameworkGenerator } from "frameworks/index"; +import { installPackages } from "helpers/packages"; +import type { TemplateConfig } from "../../src/templates"; +import type { C3Context } from "types"; + +const generate = async (ctx: C3Context) => { + await runFrameworkGenerator(ctx, [ + ctx.project.name, + "--ts", + "--tailwind", + "--eslint", + "--app", + "--import-alias", + "@/*", + "--src-dir", + ]); +}; + +const configure = async () => { + const packages = ["@opennextjs/cloudflare", "@cloudflare/workers-types"]; + await installPackages(packages, { + dev: true, + startText: "Adding the Cloudflare adapter", + doneText: `${brandColor(`installed`)} ${dim(packages.join(", "))}`, + }); +}; + +export default { + configVersion: 1, + id: "next", + frameworkCli: "create-next-app", + platform: "workers", + displayName: "Next (using Node.js compat + Workers Assets)", + path: "templates-experimental/next", + copyFiles: { + path: "./templates", + }, + generate, + configure, + transformPackageJson: async () => ({ + scripts: { + deploy: `cloudflare && wrangler deploy`, + preview: `cloudflare && wrangler dev`, + "cf-typegen": `wrangler types --env-interface CloudflareEnv env.d.ts`, + }, + }), + devScript: "dev", + previewScript: "preview", + deployScript: "deploy", + compatibilityFlags: ["nodejs_compat"], +} as TemplateConfig; diff --git a/packages/create-cloudflare/templates-experimental/next/templates/.gitignore b/packages/create-cloudflare/templates-experimental/next/templates/.gitignore new file mode 100644 index 000000000000..0b418115f12a --- /dev/null +++ b/packages/create-cloudflare/templates-experimental/next/templates/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + + +# Cloudflare related +/.save.next +/.worker-next +/.wrangler diff --git a/packages/create-cloudflare/templates-experimental/next/templates/env.d.ts b/packages/create-cloudflare/templates-experimental/next/templates/env.d.ts new file mode 100644 index 000000000000..68a2a989df06 --- /dev/null +++ b/packages/create-cloudflare/templates-experimental/next/templates/env.d.ts @@ -0,0 +1,5 @@ +// Generated by Wrangler +// by running `wrangler types --env-interface CloudflareEnv env.d.ts` + +interface CloudflareEnv { +} diff --git a/packages/create-cloudflare/templates-experimental/next/templates/wrangler.toml b/packages/create-cloudflare/templates-experimental/next/templates/wrangler.toml new file mode 100644 index 000000000000..4f7e9f1324c4 --- /dev/null +++ b/packages/create-cloudflare/templates-experimental/next/templates/wrangler.toml @@ -0,0 +1,12 @@ +#:schema node_modules/wrangler/config-schema.json +name = "" +main = ".worker-next/index.mjs" + +compatibility_date = "2024-09-26" +compatibility_flags = ["nodejs_compat"] + +# Minification helps to keep the Worker bundle size down and improve start up time. +minify = true + +# Use the new Workers + Assets to host the static frontend files +assets = { directory = ".worker-next/assets", binding = "ASSETS" } diff --git a/packages/create-cloudflare/turbo.json b/packages/create-cloudflare/turbo.json index a3479eb8dd53..0302314d9ad9 100644 --- a/packages/create-cloudflare/turbo.json +++ b/packages/create-cloudflare/turbo.json @@ -23,7 +23,8 @@ "E2E_QUARANTINE", "E2E_PROJECT_PATH", "E2E_RETRIES", - "E2E_NO_DEPLOY" + "E2E_NO_DEPLOY", + "E2E_EXPERIMENTAL" ], "dependsOn": ["build"] } diff --git a/packages/create-cloudflare/vitest-e2e.config.mts b/packages/create-cloudflare/vitest-e2e.config.mts index b704f9493305..d4f8b39580fb 100644 --- a/packages/create-cloudflare/vitest-e2e.config.mts +++ b/packages/create-cloudflare/vitest-e2e.config.mts @@ -12,7 +12,7 @@ export default defineConfig({ setupFiles: ["e2e-tests/setup.ts", "dotenv/config"], reporters: ["json", "verbose", "hanging-process"], outputFile: { - json: "./.e2e-logs/" + process.env.TEST_PM + "/results.json", + json: `./.e2e-logs${process.env.E2E_EXPERIMENTAL ? "-experimental" : ""}/${process.env.TEST_PM}/results.json`, }, }, });