diff --git a/.vscode/settings.json b/.vscode/settings.json index af3e13c4c8a8..749a67f2520b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,8 @@ "cSpell.words": [ "Abortable", "assetsignore", + "astro", + "cf-typegen", "cfetch", "chatgpt", "clipboardy", @@ -15,6 +17,7 @@ "filestat", "haikunate", "haikunator", + "Hono", "httplogs", "iarna", "isolinear", @@ -27,6 +30,7 @@ "mrbbot", "mtls", "nodeless", + "Nuxt", "outdir", "outfile", "pgrep", diff --git a/packages/create-cloudflare/e2e-tests/cli.test.ts b/packages/create-cloudflare/e2e-tests/cli.test.ts index 5b4e25aa1945..afb6eae535f6 100644 --- a/packages/create-cloudflare/e2e-tests/cli.test.ts +++ b/packages/create-cloudflare/e2e-tests/cli.test.ts @@ -1,24 +1,13 @@ -import { existsSync, mkdtempSync, realpathSync, rmSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; -import { - afterEach, - beforeAll, - beforeEach, - describe, - expect, - test, -} from "vitest"; +import { beforeAll, describe, expect } from "vitest"; import { version } from "../package.json"; import { getFrameworkToTest } from "./frameworkToTest"; import { - createTestLogStream, isQuarantineMode, keys, recreateLogFolder, runC3, + test, } from "./helpers"; -import type { WriteStream } from "fs"; import type { Suite } from "vitest"; const experimental = Boolean(process.env.E2E_EXPERIMENTAL); @@ -26,40 +15,28 @@ 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(experimental || frameworkToTest || isQuarantineMode())( - "E2E: Basic C3 functionality ", - () => { - const tmpDirPath = realpathSync(mkdtempSync(join(tmpdir(), "c3-tests"))); - const projectPath = join(tmpDirPath, "basic-tests"); - let logStream: WriteStream; - +describe + .skipIf(experimental || frameworkToTest || isQuarantineMode()) + .concurrent("E2E: Basic C3 functionality ", () => { beforeAll((ctx) => { recreateLogFolder({ experimental }, ctx as Suite); }); - beforeEach((ctx) => { - rmSync(projectPath, { recursive: true, force: true }); - logStream = createTestLogStream({ experimental }, ctx); - }); - - afterEach(() => { - if (existsSync(projectPath)) { - rmSync(projectPath, { recursive: true }); - } - }); - - test("--version", async () => { + test({ experimental })("--version", async ({ logStream }) => { const { output } = await runC3(["--version"], [], logStream); expect(output).toEqual(version); }); - test("--version with positionals", async () => { - const argv = ["foo", "bar", "baz", "--version"]; - const { output } = await runC3(argv, [], logStream); - expect(output).toEqual(version); - }); + test({ experimental })( + "--version with positionals", + async ({ logStream }) => { + const argv = ["foo", "bar", "baz", "--version"]; + const { output } = await runC3(argv, [], logStream); + expect(output).toEqual(version); + }, + ); - test("--version with flags", async () => { + test({ experimental })("--version with flags", async ({ logStream }) => { const argv = [ "foo", "--type", @@ -71,11 +48,11 @@ describe.skipIf(experimental || frameworkToTest || isQuarantineMode())( expect(output).toEqual(version); }); - test.skipIf(process.platform === "win32")( + test({ experimental }).skipIf(process.platform === "win32")( "Using arrow keys + enter", - async () => { + async ({ logStream, project }) => { const { output } = await runC3( - [projectPath], + [project.path], [ { matcher: /What would you like to start with\?/, @@ -101,7 +78,7 @@ describe.skipIf(experimental || frameworkToTest || isQuarantineMode())( logStream, ); - expect(projectPath).toExist(); + expect(project.path).toExist(); expect(output).toContain(`category Hello World example`); expect(output).toContain(`type Hello World Worker`); expect(output).toContain(`lang TypeScript`); @@ -110,16 +87,16 @@ describe.skipIf(experimental || frameworkToTest || isQuarantineMode())( }, ); - test.skipIf(process.platform === "win32")( + test({ experimental }).skipIf(process.platform === "win32")( "Typing custom responses", - async () => { + async ({ logStream, project }) => { const { output } = await runC3( [], [ { matcher: /In which directory do you want to create your application/, - input: [projectPath, keys.enter], + input: [project.path, keys.enter], }, { matcher: /What would you like to start with\?/, @@ -145,7 +122,7 @@ describe.skipIf(experimental || frameworkToTest || isQuarantineMode())( logStream, ); - expect(projectPath).toExist(); + expect(project.path).toExist(); expect(output).toContain(`type Scheduled Worker (Cron Trigger)`); expect(output).toContain(`lang JavaScript`); expect(output).toContain(`no git`); @@ -153,11 +130,11 @@ describe.skipIf(experimental || frameworkToTest || isQuarantineMode())( }, ); - test.skipIf(process.platform === "win32")( + test({ experimental }).skipIf(process.platform === "win32")( "Mixed args and interactive", - async () => { + async ({ logStream, project }) => { const { output } = await runC3( - [projectPath, "--ts", "--no-deploy"], + [project.path, "--ts", "--no-deploy"], [ { matcher: /What would you like to start with\?/, @@ -175,7 +152,7 @@ describe.skipIf(experimental || frameworkToTest || isQuarantineMode())( logStream, ); - expect(projectPath).toExist(); + expect(project.path).toExist(); expect(output).toContain(`type Hello World Worker`); expect(output).toContain(`lang TypeScript`); expect(output).toContain(`no git`); @@ -183,12 +160,12 @@ describe.skipIf(experimental || frameworkToTest || isQuarantineMode())( }, ); - test.skipIf(process.platform === "win32")( + test({ experimental }).skipIf(process.platform === "win32")( "Cloning remote template with full GitHub URL", - async () => { + async ({ logStream, project }) => { const { output } = await runC3( [ - projectPath, + project.path, "--template=https://github.com/cloudflare/templates/worker-router", "--no-deploy", "--git=false", @@ -207,13 +184,13 @@ describe.skipIf(experimental || frameworkToTest || isQuarantineMode())( }, ); - test.skipIf(process.platform === "win32")( + test({ experimental }).skipIf(process.platform === "win32")( "Inferring the category, type and language if the type is `hello-world-python`", - async () => { + async ({ logStream, project }) => { // The `hello-world-python` template is now the python variant of the `hello-world` template const { output } = await runC3( [ - projectPath, + project.path, "--type=hello-world-python", "--no-deploy", "--git=false", @@ -222,18 +199,18 @@ describe.skipIf(experimental || frameworkToTest || isQuarantineMode())( logStream, ); - expect(projectPath).toExist(); + expect(project.path).toExist(); expect(output).toContain(`category Hello World example`); expect(output).toContain(`type Hello World Worker`); expect(output).toContain(`lang Python`); }, ); - test.skipIf(process.platform === "win32")( + test({ experimental }).skipIf(process.platform === "win32")( "Selecting template by description", - async () => { + async ({ logStream, project }) => { const { output } = await runC3( - [projectPath, "--no-deploy", "--git=false"], + [project.path, "--no-deploy", "--git=false"], [ { matcher: /What would you like to start with\?/, @@ -257,17 +234,17 @@ describe.skipIf(experimental || frameworkToTest || isQuarantineMode())( logStream, ); - expect(projectPath).toExist(); + expect(project.path).toExist(); expect(output).toContain(`category Application Starter`); expect(output).toContain(`type API starter (OpenAPI compliant)`); }, ); - test.skipIf(process.platform === "win32")( + test({ experimental }).skipIf(process.platform === "win32")( "Going back and forth between the category, type, framework and lang prompts", - async () => { + async ({ logStream, project }) => { const { output } = await runC3( - [projectPath, "--git=false", "--no-deploy"], + [project.path, "--git=false", "--no-deploy"], [ { matcher: /What would you like to start with\?/, @@ -355,10 +332,9 @@ describe.skipIf(experimental || frameworkToTest || isQuarantineMode())( logStream, ); - expect(projectPath).toExist(); + expect(project.path).toExist(); expect(output).toContain(`type Hello World Worker`); expect(output).toContain(`lang JavaScript`); }, ); - }, -); + }); diff --git a/packages/create-cloudflare/e2e-tests/fixtures/analog/wrangler.toml b/packages/create-cloudflare/e2e-tests/fixtures/analog/wrangler.toml deleted file mode 100644 index 4679b8cbbddd..000000000000 --- a/packages/create-cloudflare/e2e-tests/fixtures/analog/wrangler.toml +++ /dev/null @@ -1,2 +0,0 @@ -[vars] -TEST = "C3_TEST" diff --git a/packages/create-cloudflare/e2e-tests/fixtures/astro/wrangler.toml b/packages/create-cloudflare/e2e-tests/fixtures/astro/wrangler.toml deleted file mode 100644 index 4679b8cbbddd..000000000000 --- a/packages/create-cloudflare/e2e-tests/fixtures/astro/wrangler.toml +++ /dev/null @@ -1,2 +0,0 @@ -[vars] -TEST = "C3_TEST" diff --git a/packages/create-cloudflare/e2e-tests/fixtures/nuxt/wrangler.toml b/packages/create-cloudflare/e2e-tests/fixtures/nuxt/wrangler.toml deleted file mode 100644 index 4679b8cbbddd..000000000000 --- a/packages/create-cloudflare/e2e-tests/fixtures/nuxt/wrangler.toml +++ /dev/null @@ -1,2 +0,0 @@ -[vars] -TEST = "C3_TEST" diff --git a/packages/create-cloudflare/e2e-tests/fixtures/qwik/src/routes/test/index.ts b/packages/create-cloudflare/e2e-tests/fixtures/qwik/src/routes/test/index.ts index 5e6d9d030a81..347a2652e0d9 100644 --- a/packages/create-cloudflare/e2e-tests/fixtures/qwik/src/routes/test/index.ts +++ b/packages/create-cloudflare/e2e-tests/fixtures/qwik/src/routes/test/index.ts @@ -6,5 +6,5 @@ export const onGet: RequestHandler = async ({ platform, json }) => { return; } - json(200, { value: platform.env["TEST"], success: true }); + json(200, { value: (platform.env as any)["TEST"], success: true }); }; diff --git a/packages/create-cloudflare/e2e-tests/fixtures/qwik/wrangler.toml b/packages/create-cloudflare/e2e-tests/fixtures/qwik/wrangler.toml deleted file mode 100644 index 4679b8cbbddd..000000000000 --- a/packages/create-cloudflare/e2e-tests/fixtures/qwik/wrangler.toml +++ /dev/null @@ -1,2 +0,0 @@ -[vars] -TEST = "C3_TEST" diff --git a/packages/create-cloudflare/e2e-tests/fixtures/remix/wrangler.toml b/packages/create-cloudflare/e2e-tests/fixtures/remix/wrangler.toml deleted file mode 100644 index 4679b8cbbddd..000000000000 --- a/packages/create-cloudflare/e2e-tests/fixtures/remix/wrangler.toml +++ /dev/null @@ -1,2 +0,0 @@ -[vars] -TEST = "C3_TEST" diff --git a/packages/create-cloudflare/e2e-tests/fixtures/svelte/wrangler.toml b/packages/create-cloudflare/e2e-tests/fixtures/svelte/wrangler.toml deleted file mode 100644 index 4679b8cbbddd..000000000000 --- a/packages/create-cloudflare/e2e-tests/fixtures/svelte/wrangler.toml +++ /dev/null @@ -1,2 +0,0 @@ -[vars] -TEST = "C3_TEST" diff --git a/packages/create-cloudflare/e2e-tests/frameworks.test.ts b/packages/create-cloudflare/e2e-tests/frameworks.test.ts index af20fbcb1805..f89f9adacbe4 100644 --- a/packages/create-cloudflare/e2e-tests/frameworks.test.ts +++ b/packages/create-cloudflare/e2e-tests/frameworks.test.ts @@ -1,46 +1,38 @@ -import assert from "assert"; import { existsSync } from "fs"; import { cp } from "fs/promises"; import { join } from "path"; import { runCommand } from "helpers/command"; -import { readFile, writeFile } from "helpers/files"; +import { readFile, readToml, writeFile, writeToml } from "helpers/files"; import { detectPackageManager } from "helpers/packageManagers"; import { retry } from "helpers/retry"; import { sleep } from "helpers/sleep"; import { fetch } from "undici"; -import { - afterEach, - beforeAll, - beforeEach, - describe, - expect, - test, -} from "vitest"; +import { beforeAll, describe, expect } from "vitest"; import { deleteProject, deleteWorker } from "../scripts/common"; import { getFrameworkMap } from "../src/templates"; import { getFrameworkToTest } from "./frameworkToTest"; import { - createTestLogStream, getDiffsPath, isQuarantineMode, keys, + kill, recreateDiffsFolder, recreateLogFolder, runC3, spawnWithLogging, + test, testDeploymentCommitMessage, - testProjectDir, waitForExit, } from "./helpers"; import type { TemplateConfig } from "../src/templates"; import type { RunnerConfig } from "./helpers"; -import type { WriteStream } from "fs"; -import type { Suite } from "vitest"; +import type { JsonMap } from "@iarna/toml"; +import type { Writable } from "stream"; const TEST_TIMEOUT = 1000 * 60 * 5; const LONG_TIMEOUT = 1000 * 60 * 10; const TEST_PM = process.env.TEST_PM ?? ""; -const NO_DEPLOY = process.env.E2E_NO_DEPLOY ?? false; +const NO_DEPLOY = process.env.E2E_NO_DEPLOY ?? true; const TEST_RETRIES = process.env.E2E_RETRIES ? parseInt(process.env.E2E_RETRIES) : 1; @@ -49,10 +41,6 @@ type FrameworkTestConfig = RunnerConfig & { testCommitMessage: boolean; unsupportedPms?: string[]; unsupportedOSs?: string[]; - verifyDev?: { - route: string; - expectedText: string; - }; verifyBuildCfTypes?: { outputFile: string; envInterfaceName: string; @@ -79,6 +67,10 @@ function getFrameworkTests(opts: { outputFile: "env.d.ts", envInterfaceName: "CloudflareEnv", }, + verifyPreview: { + route: "/test", + expectedText: "Create Next App", + }, verifyDeploy: { route: "/", expectedText: "Create Next App", @@ -97,7 +89,7 @@ function getFrameworkTests(opts: { route: "/", expectedText: "Hello, Astronaut!", }, - verifyDev: { + verifyPreview: { route: "/test", expectedText: "C3_TEST", }, @@ -126,6 +118,10 @@ function getFrameworkTests(opts: { route: "/", expectedText: "Dinosaurs are cool", }, + verifyPreview: { + route: "/", + expectedText: "Dinosaurs are cool", + }, flags: [`--package-manager`, pm], promptHandlers: [ { @@ -145,7 +141,7 @@ function getFrameworkTests(opts: { route: "/", expectedText: "The fullstack meta-framework for Angular!", }, - verifyDev: { + verifyPreview: { route: "/api/v1/test", expectedText: "C3_TEST", }, @@ -171,6 +167,10 @@ function getFrameworkTests(opts: { route: "/", expectedText: "Congratulations! Your app is running.", }, + verifyPreview: { + route: "/", + expectedText: "Congratulations! Your app is running.", + }, flags: ["--style", "sass"], }, gatsby: { @@ -187,6 +187,10 @@ function getFrameworkTests(opts: { route: "/", expectedText: "Gatsby!", }, + verifyPreview: { + route: "/", + expectedText: "Gatsby!", + }, }, hono: { testCommitMessage: false, @@ -195,6 +199,10 @@ function getFrameworkTests(opts: { route: "/", expectedText: "Hello Hono!", }, + verifyPreview: { + route: "/", + expectedText: "Hello Hono!", + }, promptHandlers: [ { matcher: /Do you want to install project dependencies\?/, @@ -216,20 +224,14 @@ function getFrameworkTests(opts: { route: "/", expectedText: "Welcome to Qwik", }, - verifyDev: { - route: "/test", - expectedText: "C3_TEST", + verifyPreview: { + route: "/", + expectedText: "Welcome to Qwik", }, verifyBuildCfTypes: { outputFile: "worker-configuration.d.ts", envInterfaceName: "Env", }, - verifyBuild: { - outputDir: "./dist", - script: "build", - route: "/test", - expectedText: "C3_TEST", - }, }, remix: { testCommitMessage: true, @@ -240,7 +242,7 @@ function getFrameworkTests(opts: { route: "/", expectedText: "Welcome to Remix", }, - verifyDev: { + verifyPreview: { route: "/test", expectedText: "C3_TEST", }, @@ -273,6 +275,10 @@ function getFrameworkTests(opts: { route: "/", expectedText: "Create Next App", }, + verifyPreview: { + route: "/", + expectedText: "Create Next App", + }, flags: [ "--typescript", "--no-install", @@ -292,7 +298,7 @@ function getFrameworkTests(opts: { route: "/", expectedText: "Welcome to Nuxt!", }, - verifyDev: { + verifyPreview: { route: "/test", expectedText: "C3_TEST", }, @@ -322,6 +328,10 @@ function getFrameworkTests(opts: { route: "/", expectedText: "Vite + React", }, + verifyPreview: { + route: "/", + expectedText: "Vite + React", + }, }, solid: { promptHandlers: [ @@ -342,6 +352,10 @@ function getFrameworkTests(opts: { route: "/", expectedText: "Hello world", }, + verifyPreview: { + route: "/", + expectedText: "Hello world", + }, }, svelte: { promptHandlers: [ @@ -365,7 +379,7 @@ function getFrameworkTests(opts: { route: "/", expectedText: "SvelteKit app", }, - verifyDev: { + verifyPreview: { route: "/test", expectedText: "C3_TEST", }, @@ -383,6 +397,10 @@ function getFrameworkTests(opts: { route: "/", expectedText: "Vite App", }, + verifyPreview: { + route: "/", + expectedText: "Vite App", + }, flags: ["--ts"], quarantine: true, }, @@ -397,50 +415,18 @@ const frameworkTests = getFrameworkTests({ experimental }); describe.concurrent( `E2E: Web frameworks (experimental:${experimental})`, () => { - let logStream: WriteStream; - beforeAll(async (ctx) => { - recreateLogFolder({ experimental }, ctx as Suite); + recreateLogFolder({ experimental }, ctx); 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)( + test({ experimental }).runIf(shouldRunTest(frameworkId, testConfig))( frameworkId, - async () => { - const { getPath, getName, clean } = testProjectDir("pages"); - const projectPath = getPath(frameworkId); - const projectName = getName(frameworkId); - + async ({ logStream, project }) => { if (!testConfig.verifyDeploy) { expect( true, @@ -452,7 +438,7 @@ describe.concurrent( try { const deploymentUrl = await runCli( frameworkId, - projectPath, + project.path, logStream, { argv: [ @@ -464,19 +450,21 @@ describe.concurrent( ); // Relevant project files should have been created - expect(projectPath).toExist(); - const pkgJsonPath = join(projectPath, "package.json"); + expect(project.path).toExist(); + const pkgJsonPath = join(project.path, "package.json"); expect(pkgJsonPath).toExist(); // Wrangler should be installed - const wranglerPath = join(projectPath, "node_modules/wrangler"); + const wranglerPath = join(project.path, "node_modules/wrangler"); expect(wranglerPath).toExist(); + await addTestVarsToWranglerToml(project.path); + // Make a request to the deployed project and verify it was successful await verifyDeployment( testConfig, frameworkId, - projectName, + project.name, `${deploymentUrl}${testConfig.verifyDeploy.route}`, testConfig.verifyDeploy.expectedText, ); @@ -484,33 +472,32 @@ describe.concurrent( // Copy over any test fixture files const fixturePath = join(__dirname, "fixtures", frameworkId); if (existsSync(fixturePath)) { - await cp(fixturePath, projectPath, { + await cp(fixturePath, project.path, { recursive: true, force: true, }); } - await verifyDevScript( + await verifyPreviewScript( testConfig, frameworkConfig, - projectPath, + project.path, logStream, ); - await verifyBuildCfTypesScript(testConfig, projectPath, logStream); - await verifyBuildScript(testConfig, projectPath, logStream); - await storeDiff(frameworkId, projectPath, { experimental }); + await verifyBuildCfTypesScript(testConfig, project.path, logStream); + await verifyBuildScript(testConfig, project.path, logStream); + await storeDiff(frameworkId, project.path, { 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); + await deleteWorker(project.name); } else { - await deleteProject(projectName); + await deleteProject(project.name); } } }, @@ -545,8 +532,11 @@ const storeDiff = async ( const runCli = async ( framework: string, projectPath: string, - logStream: WriteStream, - { argv = [], promptHandlers = [] }: RunnerConfig, + logStream: Writable, + { + argv = [], + promptHandlers = [], + }: Pick, ) => { const args = [ projectPath, @@ -579,6 +569,27 @@ const runCli = async ( return match[1]; }; +/** + * Either update or create a wrangler.toml to include a `TEST` var. + * + * This is rather than having a wrangler.toml in the e2e test's fixture folder, + * which overwrites any that comes from the framework's template. + */ +const addTestVarsToWranglerToml = async (projectPath: string) => { + const wranglerTomlPath = join(projectPath, "wrangler.toml"); + let wranglerToml: JsonMap = {}; + const wranglerTomlExists = existsSync(wranglerTomlPath); + if (wranglerTomlExists) { + wranglerToml = readToml(wranglerTomlPath); + } + + // Add a TEST var to the wrangler.toml + wranglerToml.vars ??= {}; + (wranglerToml.vars as JsonMap).TEST = "C3_TEST"; + + writeToml(wranglerTomlPath, wranglerToml); +}; + const verifyDeployment = async ( { testCommitMessage }: FrameworkTestConfig, frameworkId: string, @@ -606,26 +617,24 @@ const verifyDeployment = async ( }); }; -const verifyDevScript = async ( - { verifyDev }: FrameworkTestConfig, - { devScript }: TemplateConfig, +const verifyPreviewScript = async ( + { verifyPreview }: FrameworkTestConfig, + { previewScript }: TemplateConfig, projectPath: string, - logStream: WriteStream, + logStream: Writable, ) => { - if (!verifyDev) { + if (!verifyPreview || !previewScript) { return; } - assert(devScript !== undefined, "Expected `devScript` to be defined"); - - // Run the devserver on a random port to avoid colliding with other tests + // Run the dev-server on a random port to avoid colliding with other tests const TEST_PORT = Math.ceil(Math.random() * 1000) + 20000; const proc = spawnWithLogging( [ pm, "run", - devScript, + previewScript, ...(pm === "npm" ? ["--"] : []), "--port", `${TEST_PORT}`, @@ -633,41 +642,38 @@ const verifyDevScript = async ( { cwd: projectPath, env: { - NODE_ENV: "development", VITEST: undefined, }, }, logStream, ); - // Retry requesting the test route from the devserver - await retry({ times: 10 }, async () => { - await sleep(2000); - const res = await fetch(`http://localhost:${TEST_PORT}${verifyDev.route}`); - const body = await res.text(); - if (!body.match(verifyDev?.expectedText)) { - throw new Error("Expected text not found in response from devserver."); - } - }); - - // Make a request to the specified test route - const res = await fetch(`http://localhost:${TEST_PORT}${verifyDev.route}`); - const body = await res.text(); - - // Kill the process gracefully so ports can be cleaned up - proc.kill("SIGINT"); - - // Wait for a second to allow process to exit cleanly. Otherwise, the port might - // end up camped and cause future runs to fail - await sleep(1000); - - expect(body).toContain(verifyDev.expectedText); + try { + // Wait for the dev-server to be ready + await retry( + { times: 20, sleepMs: 5000 }, + async () => + await fetch(`http://127.0.0.1:${TEST_PORT}${verifyPreview.route}`), + ); + + // Make a request to the specified test route + const res = await fetch( + `http://127.0.0.1:${TEST_PORT}${verifyPreview.route}`, + ); + expect(await res.text()).toContain(verifyPreview.expectedText); + } finally { + // Kill the process gracefully so ports can be cleaned up + await kill(proc); + // Wait for a second to allow process to exit cleanly. Otherwise, the port might + // end up camped and cause future runs to fail + await sleep(1000); + } }; const verifyBuildCfTypesScript = async ( { verifyBuildCfTypes }: FrameworkTestConfig, projectPath: string, - logStream: WriteStream, + logStream: Writable, ) => { if (!verifyBuildCfTypes) { return; @@ -709,7 +715,7 @@ const verifyBuildCfTypesScript = async ( const verifyBuildScript = async ( { verifyBuild }: FrameworkTestConfig, projectPath: string, - logStream: WriteStream, + logStream: Writable, ) => { if (!verifyBuild) { return; @@ -745,11 +751,11 @@ const verifyBuildScript = async ( await sleep(7000); // Make a request to the specified test route - const res = await fetch(`http://localhost:${TEST_PORT}${route}`); + const res = await fetch(`http://127.0.0.1:${TEST_PORT}${route}`); const body = await res.text(); // Kill the process gracefully so ports can be cleaned up - devProc.kill("SIGINT"); + await kill(devProc); // Wait for a second to allow process to exit cleanly. Otherwise, the port might // end up camped and cause future runs to fail @@ -758,3 +764,24 @@ const verifyBuildScript = async ( // Verify expectation after killing the process so that it exits cleanly in case of failure expect(body).toContain(expectedText); }; + +function shouldRunTest(frameworkId: string, testConfig: FrameworkTestConfig) { + 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); + + return shouldRun; +} diff --git a/packages/create-cloudflare/e2e-tests/helpers.ts b/packages/create-cloudflare/e2e-tests/helpers.ts index f91737a11faa..479bbb619764 100644 --- a/packages/create-cloudflare/e2e-tests/helpers.ts +++ b/packages/create-cloudflare/e2e-tests/helpers.ts @@ -13,15 +13,17 @@ import { setTimeout } from "timers/promises"; import { stripAnsi } from "@cloudflare/cli"; import { spawn } from "cross-spawn"; import { retry } from "helpers/retry"; +import treeKill from "tree-kill"; import { fetch } from "undici"; -import { expect } from "vitest"; +import { expect, test as originalTest } from "vitest"; import { version } from "../package.json"; import type { + ChildProcess, ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio, } from "child_process"; -import type { WriteStream } from "fs"; -import type { Suite, TaskContext } from "vitest"; +import type { Writable } from "stream"; +import type { RunnerTestCase, Suite, Test } from "vitest"; export const C3_E2E_PREFIX = "tmp-e2e-c3"; @@ -64,7 +66,11 @@ export type RunnerConfig = { argv?: string[]; quarantine?: boolean; timeout?: number; - verifyDeploy?: { + verifyDeploy: null | { + route: string; + expectedText: string; + }; + verifyPreview: null | { route: string; expectedText: string; }; @@ -73,7 +79,7 @@ export type RunnerConfig = { export const runC3 = async ( argv: string[] = [], promptHandlers: PromptHandler[] = [], - logStream: WriteStream, + logStream: Writable, ) => { const cmd = ["node", "./dist/cli.js", ...argv]; const proc = spawnWithLogging(cmd, { env: testEnv }, logStream); @@ -185,7 +191,7 @@ export const runC3 = async ( /** * Spawn a child process and attach a handler that will log any output from - * `stdout` or errors from `stderror` to a dedicated log file. + * `stdout` or errors from `stderr` to a dedicated log file. * * @param args The command and arguments as an array * @param opts Additional options to be passed to the `spawn` call @@ -195,7 +201,7 @@ export const runC3 = async ( export const spawnWithLogging = ( args: string[], opts: SpawnOptionsWithoutStdio, - logStream: WriteStream, + logStream: Writable, ) => { const [cmd, ...argv] = args; @@ -263,7 +269,11 @@ export const waitForExit = async ( if (code === 0) { resolve(null); } else { - rejects(code); + rejects({ + code, + output: stdout.join("\n").trim(), + errors: stderr.join("\n").trim(), + }); } }); @@ -284,17 +294,14 @@ export const waitForExit = async ( export const createTestLogStream = ( opts: { experimental: boolean }, - ctx: TaskContext, + task: RunnerTestCase, ) => { // 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(opts, ctx.task.suite), fileName), - { - flags: "a", - }, - ); + const fileName = `${normalizeTestName(task)}.ansi`; + assert(task.suite, "Expected task.suite to be defined"); + return createWriteStream(path.join(getLogPath(opts, task.suite), fileName), { + flags: "a", + }); }; export const recreateDiffsFolder = (opts: { experimental: boolean }) => { @@ -340,19 +347,19 @@ const getLogPath = (opts: { experimental: boolean }, suite: Suite) => { ); }; -const normalizeTestName = (ctx: TaskContext) => { - const baseName = ctx.task.name +const normalizeTestName = (task: Test) => { + const baseName = task.name .toLowerCase() .replace(/\s+/g, "_") // replace any whitespace with `_` .replace(/\W/g, ""); // strip special characters // Ensure that each retry gets its own log file - const retryCount = ctx.task.result?.retryCount ?? 0; + const retryCount = task.result?.retryCount ?? 0; const suffix = retryCount > 0 ? `_${retryCount}` : ""; return baseName + suffix; }; -export const testProjectDir = (suite: string) => { +export const testProjectDir = (suite: string, test: string) => { const tmpDirPath = process.env.E2E_PROJECT_PATH ?? realpathSync(mkdtempSync(path.join(tmpdir(), `c3-tests-${suite}`))); @@ -360,16 +367,18 @@ export const testProjectDir = (suite: string) => { const randomSuffix = crypto.randomBytes(4).toString("hex"); const baseProjectName = `${C3_E2E_PREFIX}${randomSuffix}`; - const getName = (suffix: string) => `${baseProjectName}-${suffix}`; - const getPath = (suffix: string) => path.join(tmpDirPath, getName(suffix)); - const clean = (suffix: string) => { + const getName = () => + // Worker project names cannot be longer than 58 characters + `${baseProjectName}-${test.substring(0, 57 - baseProjectName.length)}`; + const getPath = () => path.join(tmpDirPath, getName()); + const clean = () => { try { if (process.env.E2E_PROJECT_PATH) { return; } realpathSync(mkdtempSync(path.join(tmpdir(), `c3-tests-${suite}`))); - const filepath = getPath(suffix); + const filepath = getPath(); rmSync(filepath, { recursive: true, force: true, @@ -444,3 +453,34 @@ export const testDeploymentCommitMessage = async ( export const isQuarantineMode = () => { return process.env.E2E_QUARANTINE === "true"; }; + +/** + * A custom Vitest `test` that is extended to provide a project path and name, and a logStream. + */ +export const test = (opts: { experimental: boolean }) => + originalTest.extend<{ + project: { path: string; name: string }; + logStream: Writable; + }>({ + async project({ task }, use) { + assert(task.suite, "Expected task.suite to be defined"); + const suite = task.suite.name + .toLowerCase() + .replaceAll(/[^a-z0-9-]/g, "-"); + const suffix = task.name.toLowerCase().replaceAll(/[^a-z0-9-]/g, "-"); + const { getPath, getName, clean } = testProjectDir(suite, suffix); + await use({ path: getPath(), name: getName() }); + clean(); + }, + async logStream({ task }, use) { + const logStream = createTestLogStream(opts, task); + await use(logStream); + logStream.close(); + }, + }); + +export function kill(proc: ChildProcess) { + return new Promise( + (resolve) => proc.pid && treeKill(proc.pid, "SIGINT", () => resolve()), + ); +} diff --git a/packages/create-cloudflare/e2e-tests/workers.test.ts b/packages/create-cloudflare/e2e-tests/workers.test.ts index d6259f37b7d9..fe33625eeb46 100644 --- a/packages/create-cloudflare/e2e-tests/workers.test.ts +++ b/packages/create-cloudflare/e2e-tests/workers.test.ts @@ -1,23 +1,26 @@ import { join } from "path"; import { readToml } from "helpers/files"; +import { detectPackageManager } from "helpers/packageManagers"; import { retry } from "helpers/retry"; import { sleep } from "helpers/sleep"; import { fetch } from "undici"; -import { beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { beforeAll, describe, expect } from "vitest"; import { deleteWorker } from "../scripts/common"; import { getFrameworkToTest } from "./frameworkToTest"; import { - createTestLogStream, isQuarantineMode, + kill, recreateLogFolder, runC3, - testProjectDir, + spawnWithLogging, + test, } from "./helpers"; import type { RunnerConfig } from "./helpers"; -import type { WriteStream } from "fs"; -import type { Suite } from "vitest"; +import type { Writable } from "stream"; const TEST_TIMEOUT = 1000 * 60 * 5; +const NO_DEPLOY = process.env.E2E_NO_DEPLOY ?? true; +const { name: pm } = detectPackageManager(); type WorkerTestConfig = RunnerConfig & { name?: string; @@ -37,6 +40,10 @@ function getWorkerTests(opts: { experimental: boolean }): WorkerTestConfig[] { route: "/", expectedText: "Hello World!", }, + verifyPreview: { + route: "/", + expectedText: "Hello World!", + }, }, { template: "common", @@ -45,16 +52,24 @@ function getWorkerTests(opts: { experimental: boolean }): WorkerTestConfig[] { route: "/", expectedText: "Try making requests to:", }, + verifyPreview: { + route: "/", + expectedText: "Try making requests to:", + }, }, { template: "queues", variants: ["TypeScript", "JavaScript"], // Skipped for now, since C3 does not yet support resource creation + verifyDeploy: null, + verifyPreview: null, }, { template: "scheduled", variants: ["TypeScript", "JavaScript"], // Skipped for now, since it's not possible to test scheduled events on deployed Workers + verifyDeploy: null, + verifyPreview: null, }, { template: "openapi", @@ -63,6 +78,10 @@ function getWorkerTests(opts: { experimental: boolean }): WorkerTestConfig[] { route: "/", expectedText: "SwaggerUI", }, + verifyPreview: { + route: "/", + expectedText: "SwaggerUI", + }, }, ]; } @@ -79,23 +98,17 @@ describe process.platform === "win32", ) .concurrent(`E2E: Workers templates`, () => { - let logStream: WriteStream; - beforeAll((ctx) => { - recreateLogFolder({ experimental }, ctx as Suite); - }); - - beforeEach(async (ctx) => { - logStream = createTestLogStream({ experimental }, ctx); + recreateLogFolder({ experimental }, ctx); }); workerTests - .flatMap((template) => - template.variants.length > 0 - ? template.variants.map((variant) => { + .flatMap((testConfig) => + testConfig.variants.length > 0 + ? testConfig.variants.map((variant) => { return { - ...template, - name: `${template.name ?? template.template}-${variant.toLowerCase()}`, + ...testConfig, + name: `${testConfig.name ?? testConfig.template}-${variant.toLowerCase()}`, promptHandlers: [ { matcher: /Which language do you want to use\?/, @@ -107,75 +120,72 @@ describe ], }; }) - : [template], + : [testConfig], ) - .forEach((template) => { - const name = template.name ?? template.template; - test( + .forEach((testConfig) => { + const name = testConfig.name ?? testConfig.template; + test({ experimental })( name, - async () => { - const { getPath, getName, clean } = testProjectDir("workers"); - const projectPath = getPath(name); - const projectName = getName(name); + async ({ project, logStream }) => { try { const deployedUrl = await runCli( - template, - projectPath, + testConfig, + project.path, logStream, ); // Relevant project files should have been created - expect(projectPath).toExist(); + expect(project.path).toExist(); - const gitignorePath = join(projectPath, ".gitignore"); + const gitignorePath = join(project.path, ".gitignore"); expect(gitignorePath).toExist(); - const pkgJsonPath = join(projectPath, "package.json"); + const pkgJsonPath = join(project.path, "package.json"); expect(pkgJsonPath).toExist(); - const wranglerPath = join(projectPath, "node_modules/wrangler"); + const wranglerPath = join(project.path, "node_modules/wrangler"); expect(wranglerPath).toExist(); - const tomlPath = join(projectPath, "wrangler.toml"); + const tomlPath = join(project.path, "wrangler.toml"); expect(tomlPath).toExist(); const config = readToml(tomlPath) as { main: string }; - expect(join(projectPath, config.main)).toExist(); + expect(join(project.path, config.main)).toExist(); - const { verifyDeploy } = template; - if (verifyDeploy && deployedUrl) { - await verifyDeployment(deployedUrl, verifyDeploy.expectedText); + const { verifyDeploy } = testConfig; + if (verifyDeploy) { + if (deployedUrl) { + await verifyDeployment(deployedUrl, verifyDeploy); + } else { + await verifyLocalDev(testConfig, project.path, logStream); + } } } finally { - clean(name); - await deleteWorker(projectName); + await deleteWorker(project.name); } }, - { retry: 1, timeout: template.timeout || TEST_TIMEOUT }, + { retry: 1, timeout: testConfig.timeout || TEST_TIMEOUT }, ); }); }); const runCli = async ( - template: WorkerTestConfig, + { argv, promptHandlers, template }: WorkerTestConfig, projectPath: string, - logStream: WriteStream, + logStream: Writable, ) => { - const { argv, promptHandlers, verifyDeploy } = template; - const args = [ projectPath, "--type", - template.template, + template, "--no-open", "--no-git", - verifyDeploy ? "--deploy" : "--no-deploy", + NO_DEPLOY ? "--no-deploy" : "--deploy", ...(argv ?? []), ]; const { output } = await runC3(args, promptHandlers, logStream); - - if (!verifyDeploy) { + if (NO_DEPLOY) { return null; } @@ -194,16 +204,71 @@ const runCli = async ( const verifyDeployment = async ( deploymentUrl: string, - expectedString: string, + verifyDeploy: { + route: string; + expectedText: string; + }, ) => { await retry({ times: 5 }, async () => { await sleep(1000); - const res = await fetch(deploymentUrl); + const res = await fetch(deploymentUrl + verifyDeploy.route); const body = await res.text(); - if (!body.includes(expectedString)) { + if (!body.includes(verifyDeploy.expectedText)) { throw new Error( - `(Deployed page (${deploymentUrl}) didn't contain expected string: "${expectedString}"`, + `(Deployed page (${deploymentUrl}) didn't contain expected string: "${verifyDeploy.expectedText}" instead got ${body}`, ); } }); }; + +const verifyLocalDev = async ( + { verifyDeploy }: WorkerTestConfig, + projectPath: string, + logStream: Writable, +) => { + if (verifyDeploy === null) { + return; + } + + // Run the dev-server on a random port to avoid colliding with other tests + const TEST_PORT = Math.ceil(Math.random() * 1000) + 20000; + + const proc = spawnWithLogging( + [ + pm, + "run", + "dev", + ...(pm === "npm" ? ["--"] : []), + "--port", + `${TEST_PORT}`, + ], + { + cwd: projectPath, + env: { + VITEST: undefined, + }, + }, + logStream, + ); + + try { + // Wait for the dev-server to be ready + await retry( + { times: 20, sleepMs: 5000 }, + async () => + await fetch(`http://127.0.0.1:${TEST_PORT}${verifyDeploy.route}`), + ); + + // Make a request to the specified test route + const res = await fetch( + `http://127.0.0.1:${TEST_PORT}${verifyDeploy.route}`, + ); + expect(await res.text()).toContain(verifyDeploy.expectedText); + } finally { + // Kill the process gracefully so ports can be cleaned up + await kill(proc); + // Wait for a second to allow process to exit cleanly. Otherwise, the port might + // end up camped and cause future runs to fail + await sleep(1000); + } +}; diff --git a/packages/create-cloudflare/src/helpers/files.ts b/packages/create-cloudflare/src/helpers/files.ts index b3ef3cf3bbd1..069c69cd31fc 100644 --- a/packages/create-cloudflare/src/helpers/files.ts +++ b/packages/create-cloudflare/src/helpers/files.ts @@ -1,6 +1,7 @@ import fs, { existsSync, statSync } from "fs"; import { join } from "path"; import TOML from "@iarna/toml"; +import type { JsonMap } from "@iarna/toml"; import type { C3Context } from "types"; export const copyFile = (path: string, dest: string) => { @@ -62,13 +63,17 @@ export const readJSON = (path: string) => { export const readToml = (path: string) => { const contents = readFile(path); - return contents ? TOML.parse(contents) : contents; + return contents ? TOML.parse(contents) : {}; }; export const writeJSON = (path: string, object: object, stringifySpace = 2) => { writeFile(path, JSON.stringify(object, null, stringifySpace)); }; +export const writeToml = (path: string, object: JsonMap) => { + writeFile(path, TOML.stringify(object)); +}; + // Probes a list of paths and returns the first one that exists or null if none does export const probePaths = (paths: string[]) => { for (const path of paths) { diff --git a/packages/create-cloudflare/src/helpers/retry.ts b/packages/create-cloudflare/src/helpers/retry.ts index c56a1b79a1b7..6435f6b8fee4 100644 --- a/packages/create-cloudflare/src/helpers/retry.ts +++ b/packages/create-cloudflare/src/helpers/retry.ts @@ -1,5 +1,8 @@ +import { sleep } from "./sleep"; + type RetryConfig = { times: number; + sleepMs?: number; exitCondition?: (e: unknown) => boolean; }; @@ -10,6 +13,7 @@ type RetryConfig = { * user experience and reduce flakiness in e2e tests. * * @param config.times - The number of times to retry the function + * @param config.sleepMs - How many ms to sleep between retries * @param config.exitCondition - The retry loop will be prematurely exited if this function returns true * @param fn - The function to retry * @@ -33,6 +37,7 @@ export const retry = async (config: RetryConfig, fn: () => Promise) => { if (config.exitCondition?.(e)) { break; } + await sleep(config.sleepMs ?? 1000); } } throw error; diff --git a/packages/create-cloudflare/templates/docusaurus/c3.ts b/packages/create-cloudflare/templates/docusaurus/c3.ts index f5bcaf62cca9..8f5ccc25b88e 100644 --- a/packages/create-cloudflare/templates/docusaurus/c3.ts +++ b/packages/create-cloudflare/templates/docusaurus/c3.ts @@ -18,10 +18,11 @@ const config: TemplateConfig = { generate, transformPackageJson: async () => ({ scripts: { + preview: `${npm} run build && wrangler pages dev ./build`, deploy: `${npm} run build && wrangler pages deploy ./build`, }, }), - devScript: "start", + devScript: "preview", deployScript: "deploy", }; export default config; diff --git a/packages/create-cloudflare/templates/nuxt/templates/wrangler.toml b/packages/create-cloudflare/templates/nuxt/templates/wrangler.toml index a03a506b7081..70bd7c7606e2 100644 --- a/packages/create-cloudflare/templates/nuxt/templates/wrangler.toml +++ b/packages/create-cloudflare/templates/nuxt/templates/wrangler.toml @@ -1,6 +1,7 @@ #:schema node_modules/wrangler/config-schema.json name = "" compatibility_date = "" +compatibility_flags = ["nodejs_compat"] pages_build_output_dir = "./dist" # Automatically place your workloads in an optimal location to minimize latency.