diff --git a/.changeset/ten-parents-double.md b/.changeset/ten-parents-double.md new file mode 100644 index 000000000000..3f7a4058b812 --- /dev/null +++ b/.changeset/ten-parents-double.md @@ -0,0 +1,8 @@ +--- +"create-cloudflare": patch +--- + +feature: Add `getBindingsProxy` support to `qwik` template + +The `qwik` template now uses `getBindingsProxy` for handling requests for bound resources +in dev. This allows projects to use `vite` for dev instead of `wrangler pages dev` on built output. diff --git a/packages/create-cloudflare/.eslintrc.js b/packages/create-cloudflare/.eslintrc.js index 1e2096d54a14..1057fd4b8aa3 100644 --- a/packages/create-cloudflare/.eslintrc.js +++ b/packages/create-cloudflare/.eslintrc.js @@ -4,6 +4,7 @@ module.exports = { ignorePatterns: [ "dist", "scripts", + "e2e-tests/fixtures/*", // template files are ignored by the eslint-config-worker configuration // we do however want the c3 files to be linted "!**/templates/**/c3.ts", diff --git a/packages/create-cloudflare/e2e-tests/cli.test.ts b/packages/create-cloudflare/e2e-tests/cli.test.ts index 174acf2cdb61..7a26171b2450 100644 --- a/packages/create-cloudflare/e2e-tests/cli.test.ts +++ b/packages/create-cloudflare/e2e-tests/cli.test.ts @@ -11,7 +11,14 @@ import { } from "vitest"; import { version } from "../package.json"; import { frameworkToTest } from "./frameworkToTest"; -import { isQuarantineMode, keys, recreateLogFolder, runC3 } from "./helpers"; +import { + createTestLogStream, + isQuarantineMode, + keys, + recreateLogFolder, + runC3, +} from "./helpers"; +import type { WriteStream } from "fs"; import type { Suite } from "vitest"; // Note: skipIf(frameworkToTest) makes it so that all the basic C3 functionality @@ -21,13 +28,15 @@ describe.skipIf(frameworkToTest || isQuarantineMode())( () => { const tmpDirPath = realpathSync(mkdtempSync(join(tmpdir(), "c3-tests"))); const projectPath = join(tmpDirPath, "basic-tests"); + let logStream: WriteStream; beforeAll((ctx) => { recreateLogFolder(ctx as Suite); }); - beforeEach(() => { + beforeEach((ctx) => { rmSync(projectPath, { recursive: true, force: true }); + logStream = createTestLogStream(ctx); }); afterEach(() => { @@ -36,18 +45,18 @@ describe.skipIf(frameworkToTest || isQuarantineMode())( } }); - test("--version", async (ctx) => { - const { output } = await runC3({ ctx, argv: ["--version"] }); + test("--version", async () => { + const { output } = await runC3(["--version"], [], logStream); expect(output).toEqual(version); }); - test("--version with positionals", async (ctx) => { + test("--version with positionals", async () => { const argv = ["foo", "bar", "baz", "--version"]; - const { output } = await runC3({ ctx, argv }); + const { output } = await runC3(argv, [], logStream); expect(output).toEqual(version); }); - test("--version with flags", async (ctx) => { + test("--version with flags", async () => { const argv = [ "foo", "--type", @@ -55,17 +64,16 @@ describe.skipIf(frameworkToTest || isQuarantineMode())( "--no-deploy", "--version", ]; - const { output } = await runC3({ ctx, argv }); + const { output } = await runC3(argv, [], logStream); expect(output).toEqual(version); }); test.skipIf(process.platform === "win32")( "Using arrow keys + enter", - async (ctx) => { - const { output } = await runC3({ - ctx, - argv: [projectPath], - promptHandlers: [ + async () => { + const { output } = await runC3( + [projectPath], + [ { matcher: /What type of application do you want to create/, input: [keys.enter], @@ -83,7 +91,8 @@ describe.skipIf(frameworkToTest || isQuarantineMode())( input: [keys.left, keys.enter], }, ], - }); + logStream + ); expect(projectPath).toExist(); expect(output).toContain(`type "Hello World" Worker`); @@ -95,11 +104,10 @@ describe.skipIf(frameworkToTest || isQuarantineMode())( test.skipIf(process.platform === "win32")( "Typing custom responses", - async (ctx) => { - const { output } = await runC3({ - argv: [], - ctx, - promptHandlers: [ + async () => { + const { output } = await runC3( + [], + [ { matcher: /In which directory do you want to create your application/, @@ -122,7 +130,8 @@ describe.skipIf(frameworkToTest || isQuarantineMode())( input: ["n"], }, ], - }); + logStream + ); expect(projectPath).toExist(); expect(output).toContain(`type Example router & proxy Worker`); @@ -134,11 +143,10 @@ describe.skipIf(frameworkToTest || isQuarantineMode())( test.skipIf(process.platform === "win32")( "Mixed args and interactive", - async (ctx) => { - const { output } = await runC3({ - ctx, - argv: [projectPath, "--ts", "--no-deploy"], - promptHandlers: [ + async () => { + const { output } = await runC3( + [projectPath, "--ts", "--no-deploy"], + [ { matcher: /What type of application do you want to create/, input: [keys.enter], @@ -148,7 +156,8 @@ describe.skipIf(frameworkToTest || isQuarantineMode())( input: ["n"], }, ], - }); + logStream + ); expect(projectPath).toExist(); expect(output).toContain(`type "Hello World" Worker`); 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 new file mode 100644 index 000000000000..c31b601352fb --- /dev/null +++ b/packages/create-cloudflare/e2e-tests/fixtures/qwik/src/routes/test/index.ts @@ -0,0 +1,10 @@ +import type { RequestHandler } from "@builder.io/qwik-city"; + +export const onGet: RequestHandler = async ({ platform, json }) => { + if (!platform.env) { + json(500, "Platform object not defined"); + return; + } + + json(200, { value: platform.env["TEST"], version: 1 }); +}; diff --git a/packages/create-cloudflare/e2e-tests/fixtures/qwik/wrangler.toml b/packages/create-cloudflare/e2e-tests/fixtures/qwik/wrangler.toml new file mode 100644 index 000000000000..4679b8cbbddd --- /dev/null +++ b/packages/create-cloudflare/e2e-tests/fixtures/qwik/wrangler.toml @@ -0,0 +1,2 @@ +[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 f8096e3a82b9..5433e81276d1 100644 --- a/packages/create-cloudflare/e2e-tests/frameworks.test.ts +++ b/packages/create-cloudflare/e2e-tests/frameworks.test.ts @@ -1,249 +1,243 @@ +import { existsSync } from "fs"; +import { cp } from "fs/promises"; import { join } from "path"; import { retry } from "helpers/command"; +import { sleep } from "helpers/common"; +import { detectPackageManager } from "helpers/packages"; import { fetch } from "undici"; -import { beforeAll, describe, expect, test } from "vitest"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "vitest"; import { deleteProject, deleteWorker } from "../scripts/common"; import { getFrameworkMap } from "../src/templates"; import { frameworkToTest } from "./frameworkToTest"; import { + createTestLogStream, isQuarantineMode, keys, recreateLogFolder, runC3, + spawnWithLogging, testDeploymentCommitMessage, testProjectDir, + waitForExit, } from "./helpers"; import type { FrameworkMap, FrameworkName } from "../src/templates"; import type { RunnerConfig } from "./helpers"; -import type { Suite, TestContext } from "vitest"; +import type { WriteStream } from "fs"; +import type { Suite } from "vitest"; const TEST_TIMEOUT = 1000 * 60 * 5; const LONG_TIMEOUT = 1000 * 60 * 10; -type FrameworkTestConfig = Omit & { - expectResponseToContain: string; +type FrameworkTestConfig = RunnerConfig & { testCommitMessage: boolean; - timeout?: number; unsupportedPms?: string[]; unsupportedOSs?: string[]; + verifyDev?: { + route: string; + expectedText: string; + }; + verifyBuild?: { + outputDir: string; + script: string; + route: string; + expectedText: string; + }; }; -let frameworkMap: FrameworkMap; - -describe.concurrent(`E2E: Web frameworks`, () => { - // These are ordered based on speed and reliability for ease of debugging - const frameworkTests: Record = { - astro: { - expectResponseToContain: "Hello, Astronaut!", - testCommitMessage: true, - unsupportedOSs: ["win32"], +// These are ordered based on speed and reliability for ease of debugging +const frameworkTests: Record = { + astro: { + testCommitMessage: true, + unsupportedOSs: ["win32"], + verifyDeploy: { + route: "/", + expectedText: "Hello, Astronaut!", }, - docusaurus: { - expectResponseToContain: "Dinosaurs are cool", - unsupportedPms: ["bun"], - testCommitMessage: true, - unsupportedOSs: ["win32"], - timeout: LONG_TIMEOUT, + }, + docusaurus: { + unsupportedPms: ["bun"], + testCommitMessage: true, + unsupportedOSs: ["win32"], + timeout: LONG_TIMEOUT, + verifyDeploy: { + route: "/", + expectedText: "Dinosaurs are cool", }, - angular: { - expectResponseToContain: "Congratulations! Your app is running.", - testCommitMessage: true, - timeout: LONG_TIMEOUT, + }, + angular: { + testCommitMessage: true, + timeout: LONG_TIMEOUT, + verifyDeploy: { + route: "/", + expectedText: "Congratulations! Your app is running.", }, - gatsby: { - expectResponseToContain: "Gatsby!", - unsupportedPms: ["bun", "pnpm"], - promptHandlers: [ - { - matcher: /Would you like to use a template\?/, - input: ["n"], - }, - ], - testCommitMessage: true, - timeout: LONG_TIMEOUT, + }, + gatsby: { + unsupportedPms: ["bun", "pnpm"], + promptHandlers: [ + { + matcher: /Would you like to use a template\?/, + input: ["n"], + }, + ], + testCommitMessage: true, + timeout: LONG_TIMEOUT, + verifyDeploy: { + route: "/", + expectedText: "Gatsby!", + }, + }, + hono: { + testCommitMessage: false, + verifyDeploy: { + route: "/", + expectedText: "Hello Hono!", }, - hono: { - expectResponseToContain: "Hello Hono!", - testCommitMessage: false, + }, + qwik: { + promptHandlers: [ + { + matcher: /Yes looks good, finish update/, + input: [keys.enter], + }, + ], + testCommitMessage: true, + unsupportedOSs: ["win32"], + unsupportedPms: ["yarn"], + verifyDeploy: { + route: "/", + expectedText: "Welcome to Qwik", }, - qwik: { - expectResponseToContain: "Welcome to Qwik", - promptHandlers: [ - { - matcher: /Yes looks good, finish update/, - input: [keys.enter], - }, - ], - testCommitMessage: true, - unsupportedOSs: ["win32"], - unsupportedPms: ["yarn"], + verifyDev: { + route: "/test", + expectedText: "C3_TEST", }, - remix: { - expectResponseToContain: "Welcome to Remix", - testCommitMessage: true, - timeout: LONG_TIMEOUT, - unsupportedPms: ["yarn"], + verifyBuild: { + outputDir: "./dist", + script: "build", + route: "/test", + expectedText: "C3_TEST", }, - next: { - expectResponseToContain: "Create Next App", - promptHandlers: [ - { - matcher: /Do you want to use the next-on-pages eslint-plugin\?/, - input: ["y"], - }, - ], - testCommitMessage: true, - quarantine: true, + }, + remix: { + testCommitMessage: true, + timeout: LONG_TIMEOUT, + unsupportedPms: ["yarn"], + verifyDeploy: { + route: "/", + expectedText: "Welcome to Remix", + }, + }, + next: { + promptHandlers: [ + { + matcher: /Do you want to use the next-on-pages eslint-plugin\?/, + input: ["y"], + }, + ], + testCommitMessage: true, + quarantine: true, + verifyDeploy: { + route: "/", + expectedText: "Create Next App", }, - nuxt: { - expectResponseToContain: "Welcome to Nuxt!", - testCommitMessage: true, - timeout: LONG_TIMEOUT, + }, + nuxt: { + testCommitMessage: true, + timeout: LONG_TIMEOUT, + verifyDeploy: { + route: "/", + expectedText: "Welcome to Nuxt!", }, - react: { - expectResponseToContain: "React App", - testCommitMessage: true, - unsupportedOSs: ["win32"], - timeout: LONG_TIMEOUT, + }, + react: { + testCommitMessage: true, + unsupportedOSs: ["win32"], + timeout: LONG_TIMEOUT, + verifyDeploy: { + route: "/", + expectedText: "React App", }, - solid: { - expectResponseToContain: "Hello world", - promptHandlers: [ - { - matcher: /Which template do you want to use/, - input: [keys.enter], - }, - { - matcher: /Server Side Rendering/, - input: [keys.enter], - }, - { - matcher: /Use TypeScript/, - input: [keys.enter], - }, - ], - testCommitMessage: true, - timeout: LONG_TIMEOUT, - unsupportedOSs: ["win32"], + }, + solid: { + promptHandlers: [ + { + matcher: /Which template do you want to use/, + input: [keys.enter], + }, + { + matcher: /Server Side Rendering/, + input: [keys.enter], + }, + { + matcher: /Use TypeScript/, + input: [keys.enter], + }, + ], + testCommitMessage: true, + timeout: LONG_TIMEOUT, + unsupportedOSs: ["win32"], + verifyDeploy: { + route: "/", + expectedText: "Hello world", }, - svelte: { - expectResponseToContain: "SvelteKit app", - 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"], + }, + 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", }, - vue: { - expectResponseToContain: "Vite App", - testCommitMessage: true, - unsupportedOSs: ["win32"], + }, + vue: { + testCommitMessage: true, + unsupportedOSs: ["win32"], + verifyDeploy: { + route: "/", + expectedText: "Vite App", }, - }; + }, +}; + +describe.concurrent(`E2E: Web frameworks`, () => { + let frameworkMap: FrameworkMap; + let logStream: WriteStream; beforeAll(async (ctx) => { frameworkMap = await getFrameworkMap(); recreateLogFolder(ctx as Suite); }); - const runCli = async ( - framework: string, - projectPath: string, - { ctx, argv = [], promptHandlers = [] }: RunnerConfig - ) => { - const args = [ - projectPath, - "--type", - "webFramework", - "--framework", - framework, - "--deploy", - "--no-open", - "--no-git", - ]; - - args.push(...argv); - - const { output } = await runC3({ - ctx, - argv: args, - promptHandlers, - outputPrefix: `[${framework}]`, - }); - - // 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(); - - // TODO: Before the refactor introduced in https://github.com/cloudflare/workers-sdk/pull/4754 - // we used to test the packageJson scripts transformations here, try to re-implement such - // checks (might be harder given the switch to a transform function compared to the old - // object based substitution) - - return { output }; - }; - - const runCliWithDeploy = async ( - framework: string, - projectName: string, - projectPath: string, - ctx: TestContext, - testCommitMessage: boolean - ) => { - const { argv, overrides, promptHandlers, expectResponseToContain } = - frameworkTests[framework]; - - const { output } = await runCli(framework, projectPath, { - ctx, - overrides, - promptHandlers, - argv: [...(argv ?? [])], - }); - - // Verify deployment - const deployedUrlRe = - /deployment is ready at: (https:\/\/.+\.(pages|workers)\.dev)/; - - const match = output.match(deployedUrlRe); - if (!match || !match[1]) { - expect(false, "Couldn't find deployment url in C3 output").toBe(true); - return; - } + beforeEach(async (ctx) => { + logStream = createTestLogStream(ctx); + }); - const projectUrl = match[1]; - - await retry({ times: 5 }, async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); // wait a second - const res = await fetch(projectUrl); - const body = await res.text(); - if (!body.includes(expectResponseToContain)) { - throw new Error( - `(${framework}) Deployed page (${projectUrl}) didn't contain expected string: "${expectResponseToContain}"` - ); - } - }); - - if (testCommitMessage) { - await testDeploymentCommitMessage(projectName, framework); - } - }; + afterEach(async () => { + logStream.close(); + }); Object.keys(frameworkTests).forEach((framework) => { const { @@ -265,23 +259,69 @@ describe.concurrent(`E2E: Web frameworks`, () => { // Skip if the package manager is unsupported shouldRun &&= !unsupportedPms?.includes(process.env.TEST_PM ?? ""); + // Skip if the OS is unsupported shouldRun &&= !unsupportedOSs?.includes(process.platform); test.runIf(shouldRun)( framework, - async (ctx) => { + async () => { const { getPath, getName, clean } = testProjectDir("pages"); const projectPath = getPath(framework); const projectName = getName(framework); const frameworkConfig = frameworkMap[framework as FrameworkName]; + + const { argv, promptHandlers, verifyDeploy } = + frameworkTests[framework]; + + if (!verifyDeploy) { + expect( + true, + "A `deploy` configuration must be defined for all framework tests" + ).toBe(false); + return; + } + try { - await runCliWithDeploy( + const deploymentUrl = await runCli( framework, - projectName, projectPath, - ctx, - testCommitMessage + logStream, + { + argv: [...(argv ?? [])], + 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(); + + if (testCommitMessage) { + await testDeploymentCommitMessage(projectName, framework); + } + + // Make a request to the deployed project and verify it was successful + await verifyDeployment( + `${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, + }); + } + + await verifyDevScript(framework, projectPath, logStream); + await verifyBuildScript(framework, projectPath, logStream); } finally { clean(framework); // Cleanup the project in case we need to retry it @@ -295,8 +335,158 @@ describe.concurrent(`E2E: Web frameworks`, () => { { retry: 1, timeout: timeout || TEST_TIMEOUT } ); }); - - // test.skip("Hono (wrangler defaults)", async (ctx) => { - // await runCli("hono", { ctx, argv: ["--wrangler-defaults"] }); - // }); }); + +const runCli = async ( + framework: string, + projectPath: string, + logStream: WriteStream, + { argv = [], promptHandlers = [] }: RunnerConfig +) => { + const args = [ + projectPath, + "--type", + "webFramework", + "--framework", + framework, + "--deploy", + "--no-open", + "--no-git", + ]; + + args.push(...argv); + + const { output } = await runC3(args, promptHandlers, logStream); + + const deployedUrlRe = + /deployment is ready at: (https:\/\/.+\.(pages|workers)\.dev)/; + + const match = output.match(deployedUrlRe); + if (!match || !match[1]) { + expect(false, "Couldn't find deployment url in C3 output").toBe(true); + return ""; + } + + return match[1]; +}; + +const verifyDeployment = async ( + deploymentUrl: string, + expectedText: string +) => { + await retry({ times: 5 }, async () => { + await sleep(1000); + const res = await fetch(deploymentUrl); + const body = await res.text(); + if (!body.includes(expectedText)) { + throw new Error( + `Deployed page (${deploymentUrl}) didn't contain expected string: "${expectedText}"` + ); + } + }); +}; + +const verifyDevScript = async ( + framework: string, + projectPath: string, + logStream: WriteStream +) => { + const { verifyDev } = frameworkTests[framework]; + if (!verifyDev) { + return; + } + + const frameworkMap = await getFrameworkMap(); + const template = frameworkMap[framework as FrameworkName]; + + // Run the devserver on a random port to avoid colliding with other tests + const TEST_PORT = Math.ceil(Math.random() * 1000) + 20000; + + const { name: pm } = detectPackageManager(); + const proc = spawnWithLogging( + [ + pm, + "run", + template.devScript as string, + pm === "npm" ? "--" : "", + "--port", + `${TEST_PORT}`, + ], + { + cwd: projectPath, + env: { + NODE_ENV: "development", + }, + }, + logStream + ); + + // Wait a few seconds for dev server to spin up + await sleep(4000); + + // 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); +}; + +const verifyBuildScript = async ( + framework: string, + projectPath: string, + logStream: WriteStream +) => { + const { verifyBuild } = frameworkTests[framework]; + + if (!verifyBuild) { + return; + } + + const { outputDir, script, route, expectedText } = verifyBuild; + + // Run the build script + const { name: pm, npx } = detectPackageManager(); + const buildProc = spawnWithLogging( + [pm, "run", script], + { + cwd: projectPath, + }, + logStream + ); + await waitForExit(buildProc); + + // Run wrangler dev on a random port to avoid colliding with other tests + const TEST_PORT = Math.ceil(Math.random() * 1000) + 20000; + + const devProc = spawnWithLogging( + [npx, "wrangler", "pages", "dev", outputDir, "--port", `${TEST_PORT}`], + { + cwd: projectPath, + }, + logStream + ); + + // Wait a few seconds for dev server to spin up + await sleep(4000); + + // Make a request to the specified test route + const res = await fetch(`http://localhost:${TEST_PORT}${route}`); + const body = await res.text(); + + // Kill the process gracefully so ports can be cleaned up + devProc.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); + + // Verify expectation after killing the process so that it exits cleanly in case of failure + expect(body).toContain(expectedText); +}; diff --git a/packages/create-cloudflare/e2e-tests/helpers.ts b/packages/create-cloudflare/e2e-tests/helpers.ts index 1f839e88ed52..1005c1b1578c 100644 --- a/packages/create-cloudflare/e2e-tests/helpers.ts +++ b/packages/create-cloudflare/e2e-tests/helpers.ts @@ -12,11 +12,14 @@ import { stripAnsi } from "@cloudflare/cli"; import { spawn } from "cross-spawn"; import { retry } from "helpers/command"; import { sleep } from "helpers/common"; -import { detectPackageManager } from "helpers/packages"; import { fetch } from "undici"; import { expect } from "vitest"; import { version } from "../package.json"; -import { quoteShellArgs } from "../src/common"; +import type { + ChildProcessWithoutNullStreams, + SpawnOptionsWithoutStdio, +} from "child_process"; +import type { WriteStream } from "fs"; import type { Suite, TestContext } from "vitest"; export const C3_E2E_PREFIX = "c3-e2e-"; @@ -31,104 +34,142 @@ export const keys = { left: "\x1b\x5b\x44", }; +const testEnv = { + ...process.env, + // The following env vars are set to ensure that package managers + // do not use the same global cache and accidentally hit race conditions. + YARN_CACHE_FOLDER: "./.yarn/cache", + YARN_ENABLE_GLOBAL_CACHE: "false", + PNPM_HOME: "./.pnpm", + npm_config_cache: "./.npm/cache", +}; + export type PromptHandler = { matcher: RegExp; input: string[]; }; export type RunnerConfig = { - overrides?: { - packageScripts?: Record; - }; promptHandlers?: PromptHandler[]; argv?: string[]; - outputPrefix?: string; quarantine?: boolean; - ctx: TestContext; + timeout?: number; + verifyDeploy?: { + route: string; + expectedText: string; + }; }; -export const runC3 = async ({ - argv = [], - promptHandlers = [], - ctx, -}: RunnerConfig) => { - const cmd = "node"; - const args = ["./dist/cli.js", ...argv]; - const proc = spawn(cmd, args, { - env: { - ...process.env, - // The following env vars are set to ensure that package managers - // do not use the same global cache and accidentally hit race conditions. - YARN_CACHE_FOLDER: "./.yarn/cache", - YARN_ENABLE_GLOBAL_CACHE: "false", - PNPM_HOME: "./.pnpm", - npm_config_cache: "./.npm/cache", - }, - }); +export const runC3 = async ( + argv: string[] = [], + promptHandlers: PromptHandler[] = [], + logStream: WriteStream +) => { + const cmd = ["node", "./dist/cli.js", ...argv]; + const proc = spawnWithLogging(cmd, { env: testEnv }, logStream); - promptHandlers = [...promptHandlers]; + // Clone the prompt handlers so we can consume them destructively + promptHandlers = promptHandlers && [...promptHandlers]; - const { name: pm } = detectPackageManager(); + const onData = (data: string) => { + const lines: string[] = data.toString().split("\n"); + const currentDialog = promptHandlers[0]; - const stdout: string[] = []; - const stderr: string[] = []; + lines.forEach(async (line) => { + if (currentDialog && currentDialog.matcher.test(line)) { + // Add a small sleep to avoid input race + await sleep(1000); - promptHandlers = promptHandlers && [...promptHandlers]; + currentDialog.input.forEach((keystroke) => { + proc.stdin.write(keystroke); + }); - // The .ansi extension allows for editor extensions that format ansi terminal codes - const logFilename = `${normalizeTestName(ctx)}.ansi`; - const logStream = createWriteStream( - join(getLogPath(ctx.task.suite), logFilename) - ); + // Consume the handler once we've used it + promptHandlers.shift(); - logStream.write( - `Running C3 with command: \`${quoteShellArgs([ - cmd, - ...args, - ])}\` (using ${pm})\n\n` - ); + // If we've consumed the last prompt handler, close the input stream + // Otherwise, the process wont exit properly + if (promptHandlers[0] === undefined) { + proc.stdin.end(); + } + } + }); + }; - await new Promise((resolve, rejects) => { - proc.stdout.on("data", (data) => { - const lines: string[] = data.toString().split("\n"); - const currentDialog = promptHandlers[0]; + return waitForExit(proc, onData); +}; - lines.forEach(async (line) => { - stdout.push(line); +/** + * Spawn a child process and attach a handler that will log any output from + * `stdout` or errors from `stderror` 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 + * @param logStream A write stream to the log file for the test + * @returns the child process that was created + */ +export const spawnWithLogging = ( + args: string[], + opts: SpawnOptionsWithoutStdio, + logStream: WriteStream +) => { + const [cmd, ...argv] = args; - const stripped = stripAnsi(line).trim(); - if (stripped.length > 0) { - logStream.write(`${stripped}\n`); - } + const proc = spawn(cmd, argv, { + ...opts, + env: { + ...testEnv, + ...opts.env, + }, + }); - if (currentDialog && currentDialog.matcher.test(line)) { - // Add a small sleep to avoid input race - await sleep(1000); + logStream.write(`\nRunning command: ${[cmd, ...argv].join(" ")}\n\n`); + + proc.stdout.on("data", (data) => { + const lines: string[] = data.toString().split("\n"); + + lines.forEach(async (line) => { + const stripped = stripAnsi(line).trim(); + if (stripped.length > 0) { + logStream.write(`${stripped}\n`); + } + }); + }); - currentDialog.input.forEach((keystroke) => { - proc.stdin.write(keystroke); - }); + proc.stderr.on("data", (data) => { + logStream.write(data); + }); - // Consume the handler once we've used it - promptHandlers.shift(); + return proc; +}; - // If we've consumed the last prompt handler, close the input stream - // Otherwise, the process wont exit properly - if (promptHandlers[0] === undefined) { - proc.stdin.end(); - } - } - }); +/** + * An async function that waits on a spawned process to run to completion, collecting + * any output or errors from `stdout` and `stderr`, respectively. + * + * @param proc The child process to wait for + * @param onData An optional handler to be called on `stdout.on('data')` + */ +export const waitForExit = async ( + proc: ChildProcessWithoutNullStreams, + onData?: (chunk: string) => void +) => { + const stdout: string[] = []; + const stderr: string[] = []; + + await new Promise((resolve, rejects) => { + proc.stdout.on("data", (data) => { + stdout.push(data); + if (onData) { + onData(data); + } }); proc.stderr.on("data", (data) => { - logStream.write(data); stderr.push(data); }); proc.on("close", (code) => { - logStream.close(); - if (code === 0) { resolve(null); } else { @@ -151,6 +192,14 @@ export const runC3 = async ({ }; }; +export const createTestLogStream = (ctx: TestContext) => { + // The .ansi extension allows for editor extensions that format ansi terminal codes + const fileName = `${normalizeTestName(ctx)}.ansi`; + return createWriteStream(join(getLogPath(ctx.task.suite), fileName), { + flags: "a", + }); +}; + export const recreateLogFolder = (suite: Suite) => { // Clean the old folder if exists (useful for dev) rmSync(getLogPath(suite), { @@ -205,7 +254,7 @@ export const testProjectDir = (suite: string) => { } catch (e) { if (typeof e === "object" && e !== null && "code" in e) { const code = e.code; - if (code === "EBUSY" || code === "ENOENT") { + if (code === "EBUSY" || code === "ENOENT" || code === "ENOTEMPTY") { return; } } diff --git a/packages/create-cloudflare/e2e-tests/workers.test.ts b/packages/create-cloudflare/e2e-tests/workers.test.ts index cc63b176d5c5..63877bbbbc05 100644 --- a/packages/create-cloudflare/e2e-tests/workers.test.ts +++ b/packages/create-cloudflare/e2e-tests/workers.test.ts @@ -1,142 +1,74 @@ import { join } from "path"; import { retry } from "helpers/command"; +import { sleep } from "helpers/common"; import { readToml } from "helpers/files"; import { fetch } from "undici"; -import { beforeAll, describe, expect, test } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { deleteWorker } from "../scripts/common"; import { frameworkToTest } from "./frameworkToTest"; import { + createTestLogStream, isQuarantineMode, recreateLogFolder, runC3, testProjectDir, } from "./helpers"; import type { RunnerConfig } from "./helpers"; -import type { Suite, TestContext } from "vitest"; +import type { WriteStream } from "fs"; +import type { Suite } from "vitest"; const TEST_TIMEOUT = 1000 * 60 * 5; -type WorkerTestConfig = Omit & { - expectResponseToContain?: string; - timeout?: number; +type WorkerTestConfig = RunnerConfig & { name?: string; template: string; }; + +const workerTemplates: WorkerTestConfig[] = [ + { + template: "hello-world", + verifyDeploy: { + route: "/", + expectedText: "Hello World!", + }, + }, + { + template: "common", + verifyDeploy: { + route: "/", + expectedText: "Try making requests to:", + }, + }, + { + template: "queues", + // Skipped for now, since C3 does not yet support resource creation + }, + { + template: "scheduled", + // Skipped for now, since it's not possible to test scheduled events on deployed Workers + }, + { + template: "openapi", + promptHandlers: [], + verifyDeploy: { + route: "/", + expectedText: "SwaggerUI", + }, + }, +]; + describe .skipIf(frameworkToTest || isQuarantineMode() || process.platform === "win32") .concurrent(`E2E: Workers templates`, () => { - const workerTemplates: WorkerTestConfig[] = [ - { - expectResponseToContain: "Hello World!", - template: "hello-world", - }, - { - template: "common", - expectResponseToContain: "Try making requests to:", - }, - { - template: "queues", - // Skipped for now, since C3 does not yet support resource creation - // expectResponseToContain: - }, - { - template: "scheduled", - // Skipped for now, since it's not possible to test scheduled events on deployed Workers - // expectResponseToContain: - }, - { - template: "openapi", - expectResponseToContain: "SwaggerUI", - promptHandlers: [], - }, - ]; + let logStream: WriteStream; beforeAll((ctx) => { recreateLogFolder(ctx as Suite); }); - const runCli = async ( - template: string, - projectPath: string, - { ctx, argv = [], promptHandlers = [] }: RunnerConfig - ) => { - const args = [projectPath, "--type", template, "--no-open", "--no-git"]; - - args.push(...argv); - - const { output } = await runC3({ - ctx, - argv: args, - promptHandlers, - outputPrefix: `[${template}]`, - }); - - // Relevant project files should have been created - expect(projectPath).toExist(); - - const gitignorePath = join(projectPath, ".gitignore"); - expect(gitignorePath).toExist(); - - const pkgJsonPath = join(projectPath, "package.json"); - expect(pkgJsonPath).toExist(); - - const wranglerPath = join(projectPath, "node_modules/wrangler"); - expect(wranglerPath).toExist(); - - const tomlPath = join(projectPath, "wrangler.toml"); - expect(tomlPath).toExist(); - - const config = readToml(tomlPath) as { main: string }; - - expect(join(projectPath, config.main)).toExist(); - - return { output }; - }; - - const runCliWithDeploy = async ( - template: WorkerTestConfig, - projectPath: string, - ctx: TestContext - ) => { - const { argv, overrides, promptHandlers, expectResponseToContain } = - template; - - const { output } = await runCli(template.template, projectPath, { - ctx, - overrides, - promptHandlers, - argv: [ - // Skip deployment if the test config has no response expectation - expectResponseToContain ? "--deploy" : "--no-deploy", - ...(argv ?? []), - ], - }); - - if (expectResponseToContain) { - // Verify deployment - const deployedUrlRe = - /deployment is ready at: (https:\/\/.+\.(workers)\.dev)/; - - const match = output.match(deployedUrlRe); - if (!match || !match[1]) { - expect(false, "Couldn't find deployment url in C3 output").toBe(true); - return; - } - - const projectUrl = match[1]; - - await retry({ times: 5 }, async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); // wait a second - const res = await fetch(projectUrl); - const body = await res.text(); - if (!body.includes(expectResponseToContain)) { - throw new Error( - `(${template}) Deployed page (${projectUrl}) didn't contain expected string: "${expectResponseToContain}"` - ); - } - }); - } - }; + beforeEach(async (ctx) => { + logStream = createTestLogStream(ctx); + }); workerTemplates .flatMap((template) => @@ -170,12 +102,39 @@ describe const name = template.name ?? template.template; test( name, - async (ctx) => { + async () => { const { getPath, getName, clean } = testProjectDir("workers"); const projectPath = getPath(name); const projectName = getName(name); try { - await runCliWithDeploy(template, projectPath, ctx); + const deployedUrl = await runCli( + template, + projectPath, + logStream + ); + + // Relevant project files should have been created + expect(projectPath).toExist(); + + const gitignorePath = join(projectPath, ".gitignore"); + expect(gitignorePath).toExist(); + + const pkgJsonPath = join(projectPath, "package.json"); + expect(pkgJsonPath).toExist(); + + const wranglerPath = join(projectPath, "node_modules/wrangler"); + expect(wranglerPath).toExist(); + + const tomlPath = join(projectPath, "wrangler.toml"); + expect(tomlPath).toExist(); + + const config = readToml(tomlPath) as { main: string }; + expect(join(projectPath, config.main)).toExist(); + + const { verifyDeploy } = template; + if (verifyDeploy && deployedUrl) { + await verifyDeployment(deployedUrl, verifyDeploy.expectedText); + } } finally { clean(name); await deleteWorker(projectName); @@ -185,3 +144,55 @@ describe ); }); }); + +const runCli = async ( + template: WorkerTestConfig, + projectPath: string, + logStream: WriteStream +) => { + const { argv, promptHandlers, verifyDeploy } = template; + + const args = [ + projectPath, + "--type", + template.template, + "--no-open", + "--no-git", + verifyDeploy ? "--deploy" : "--no-deploy", + ...(argv ?? []), + ]; + + const { output } = await runC3(args, promptHandlers, logStream); + + if (!verifyDeploy) { + return null; + } + + // Verify deployment + const deployedUrlRe = + /deployment is ready at: (https:\/\/.+\.(workers)\.dev)/; + + const match = output.match(deployedUrlRe); + if (!match || !match[1]) { + expect(false, "Couldn't find deployment url in C3 output").toBe(true); + return; + } + + return match[1]; +}; + +const verifyDeployment = async ( + deploymentUrl: string, + expectedString: string +) => { + await retry({ times: 5 }, async () => { + await sleep(1000); + const res = await fetch(deploymentUrl); + const body = await res.text(); + if (!body.includes(expectedString)) { + throw new Error( + `(Deployed page (${deploymentUrl}) didn't contain expected string: "${expectedString}"` + ); + } + }); +}; diff --git a/packages/create-cloudflare/src/cli.ts b/packages/create-cloudflare/src/cli.ts index a81694925331..08e087be787d 100644 --- a/packages/create-cloudflare/src/cli.ts +++ b/packages/create-cloudflare/src/cli.ts @@ -29,7 +29,8 @@ import { createProject } from "./pages"; import { copyTemplateFiles, selectTemplate, - updatePackageJson, + updatePackageName, + updatePackageScripts, } from "./templates"; import { installWorkersTypes, updateWranglerToml } from "./workers"; import type { C3Args, C3Context } from "types"; @@ -122,7 +123,7 @@ const create = async (ctx: C3Context) => { } await copyTemplateFiles(ctx); - await updatePackageJson(ctx); + await updatePackageName(ctx); chdir(ctx.project.path); await npmInstall(ctx); @@ -144,6 +145,8 @@ const configure = async (ctx: C3Context) => { await template.configure({ ...ctx }); } + await updatePackageScripts(ctx); + await offerGit(ctx); await gitCommit(ctx); diff --git a/packages/create-cloudflare/src/helpers/codemod.ts b/packages/create-cloudflare/src/helpers/codemod.ts index 36a0df18a0de..d1e924e1afcd 100644 --- a/packages/create-cloudflare/src/helpers/codemod.ts +++ b/packages/create-cloudflare/src/helpers/codemod.ts @@ -6,8 +6,28 @@ import * as typescriptParser from "recast/parsers/typescript"; import { readFile, writeFile } from "./files"; import type { Program } from "esprima"; +/* + CODEMOD TIPS & TRICKS + ===================== + + More info about parsing and transforming can be found in the `recast` docs: + https://github.com/benjamn/recast + + `recast` uses the `ast-types` library under the hood for basic AST operations + and defining node types. If you need to manipulate or manually construct AST nodes as + part of a code mod operation, be sure to check the `ast-types` documentation: + https://github.com/benjamn/ast-types + + Last but not least, AST viewers can be extremely helpful when trying to write + a transformer: + - https://astexplorer.net/ + - https://ts-ast-viewer.com/# + +*/ + // Parse an input string as javascript and return an ast export const parseJs = (src: string) => { + src = src.trim(); try { return recast.parse(src, { parser: esprimaParser }); } catch (error) { @@ -17,6 +37,7 @@ export const parseJs = (src: string) => { // Parse an input string as typescript and return an ast export const parseTs = (src: string) => { + src = src.trim(); try { return recast.parse(src, { parser: typescriptParser }); } catch (error) { diff --git a/packages/create-cloudflare/src/templates.ts b/packages/create-cloudflare/src/templates.ts index 3c6d4a9ca27c..78d25f36d070 100644 --- a/packages/create-cloudflare/src/templates.ts +++ b/packages/create-cloudflare/src/templates.ts @@ -367,26 +367,40 @@ const downloadRemoteTemplate = async (src: string) => { } }; -export const updatePackageJson = async (ctx: C3Context) => { - const s = spinner(); - s.start("Updating `package.json`"); - +export const updatePackageName = async (ctx: C3Context) => { // Update package.json with project name const placeholderNames = ["", "TBD", ""]; const pkgJsonPath = resolve(ctx.project.path, "package.json"); - let pkgJson = readJSON(pkgJsonPath); + const pkgJson = readJSON(pkgJsonPath); - if (placeholderNames.includes(pkgJson.name)) { - pkgJson.name = ctx.project.name; + if (!placeholderNames.includes(pkgJson.name)) { + return; } - // Run any transformers defined by the template - if (ctx.template.transformPackageJson) { - const transformed = await ctx.template.transformPackageJson(pkgJson); - pkgJson = deepmerge(pkgJson, transformed); + const s = spinner(); + s.start("Updating name in `package.json`"); + + pkgJson.name = ctx.project.name; + + writeJSON(pkgJsonPath, pkgJson); + s.stop(`${brandColor("updated")} ${dim("`package.json`")}`); +}; + +export const updatePackageScripts = async (ctx: C3Context) => { + if (!ctx.template.transformPackageJson) { + return; } - // Write the finalized package.json to disk + const s = spinner(); + s.start("Updating `package.json` scripts"); + + const pkgJsonPath = resolve(ctx.project.path, "package.json"); + let pkgJson = readJSON(pkgJsonPath); + + // Run any transformers defined by the template + const transformed = await ctx.template.transformPackageJson(pkgJson); + pkgJson = deepmerge(pkgJson, transformed); + writeJSON(pkgJsonPath, pkgJson); s.stop(`${brandColor("updated")} ${dim("`package.json`")}`); }; diff --git a/packages/create-cloudflare/templates/qwik/c3.ts b/packages/create-cloudflare/templates/qwik/c3.ts index 04764717d670..0256e90dc917 100644 --- a/packages/create-cloudflare/templates/qwik/c3.ts +++ b/packages/create-cloudflare/templates/qwik/c3.ts @@ -1,9 +1,13 @@ import { endSection } from "@cloudflare/cli"; +import { brandColor } from "@cloudflare/cli/colors"; +import { spinner } from "@cloudflare/cli/interactive"; +import { parseTs, transformFile } from "helpers/codemod"; import { runCommand, runFrameworkGenerator } from "helpers/command"; -import { compatDateFlag } from "helpers/files"; +import { usesTypescript } from "helpers/files"; import { detectPackageManager } from "helpers/packages"; import { quoteShellArgs } from "../../src/common"; import type { TemplateConfig } from "../../src/templates"; +import type * as recast from "recast"; import type { C3Context } from "types"; const { npm, npx } = detectPackageManager(); @@ -12,11 +16,62 @@ const generate = async (ctx: C3Context) => { await runFrameworkGenerator(ctx, ["basic", ctx.project.name]); }; -const configure = async () => { +const configure = async (ctx: C3Context) => { // Add the pages integration const cmd = [npx, "qwik", "add", "cloudflare-pages"]; endSection(`Running ${quoteShellArgs(cmd)}`); await runCommand(cmd); + + addBindingsProxy(ctx); +}; + +const addBindingsProxy = (ctx: C3Context) => { + // Qwik only has a typescript template atm. + // This check is an extra precaution + if (!usesTypescript(ctx)) { + return; + } + + const s = spinner(); + s.start("Updating `vite.config.ts`"); + + // Insert the env declaration after the last import (but before the rest of the body) + const envDeclaration = ` +let env = {}; + +if(process.env.NODE_ENV === 'development') { + const { getBindingsProxy } = await import('wrangler'); + const { bindings } = await getBindingsProxy(); + env = bindings; +} +`; + + transformFile("vite.config.ts", { + visitProgram: function (n) { + const lastImportIndex = n.node.body.findLastIndex( + (t) => t.type === "ImportDeclaration" + ); + n.get("body").insertAt(lastImportIndex + 1, envDeclaration); + + return false; + }, + }); + + // Populate the `qwikCity` plugin with the platform object containing the `env` defined above. + const platformObject = parseTs(`{ platform: { env } }`); + + transformFile("vite.config.ts", { + visitCallExpression: function (n) { + const callee = n.node.callee as recast.types.namedTypes.Identifier; + if (callee.name === "qwikCity") { + n.node.arguments = [platformObject]; + } + + this.traverse(n); + }, + }); + + s.stop(`${brandColor("updated")} \`vite.config.ts\``); }; const config: TemplateConfig = { @@ -24,12 +79,13 @@ const config: TemplateConfig = { id: "qwik", displayName: "Qwik", platform: "pages", + devScript: "dev", + deployScript: "deploy", generate, configure, transformPackageJson: async () => ({ scripts: { - "pages:dev": `wrangler pages dev ${await compatDateFlag()} -- ${npm} run dev`, - "pages:deploy": `${npm} run build && wrangler pages deploy ./dist`, + deploy: `${npm} run build && wrangler pages deploy ./dist`, }, }), }; diff --git a/packages/create-cloudflare/tsconfig.json b/packages/create-cloudflare/tsconfig.json index 465ea5cdbcab..0168b9a96931 100644 --- a/packages/create-cloudflare/tsconfig.json +++ b/packages/create-cloudflare/tsconfig.json @@ -5,6 +5,7 @@ "exclude": [ "node_modules", "dist", + "e2e-tests/fixtures/*", // exclude all template files other than the top level ones so // that we can catch `c3.ts`. For example, any top level files in // templates/angular/ will be included, but any directories will not