70bf21e064
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
555 lines
15 KiB
TypeScript
555 lines
15 KiB
TypeScript
import assert from "node:assert";
|
||
import { existsSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { setTimeout } from "node:timers/promises";
|
||
import { runCommand } from "@cloudflare/cli-shared-helpers/command";
|
||
import getPort from "get-port";
|
||
import {
|
||
readFile,
|
||
readJSON,
|
||
readToml,
|
||
writeJSON,
|
||
writeToml,
|
||
} from "helpers/files";
|
||
import { detectPackageManager } from "helpers/packageManagers";
|
||
import { retry } from "helpers/retry";
|
||
import * as jsonc from "jsonc-parser";
|
||
import semver from "semver";
|
||
import { fetch } from "undici";
|
||
import { version } from "../../package.json";
|
||
import { getFrameworkMap } from "../../src/templates";
|
||
import {
|
||
CLOUDFLARE_ACCOUNT_ID,
|
||
CLOUDFLARE_API_TOKEN,
|
||
isExperimental,
|
||
runDeployTests,
|
||
testPackageManager,
|
||
testPackageManagerVersion,
|
||
} from "./constants";
|
||
import { runC3 } from "./run-c3";
|
||
import { kill, spawnWithLogging } from "./spawn";
|
||
import type { TemplateConfig } from "../../src/templates";
|
||
import type { RunnerConfig } from "./run-c3";
|
||
import type { Writable } from "node:stream";
|
||
import type { ExpectStatic } from "vitest";
|
||
|
||
export type FrameworkTestConfig = RunnerConfig & {
|
||
testCommitMessage: boolean;
|
||
nodeCompat: boolean;
|
||
unsupportedPms?: string[];
|
||
unsupportedOSs?: string[];
|
||
/**
|
||
* Per–package-manager semver ranges to skip. Keys are pm names ("pnpm",
|
||
* "npm", "yarn", "bun"); values are semver ranges. Used on pnpm >=11 for
|
||
* frameworks that either run their own install (e.g. Hono) or whose
|
||
* recovery prompt fires after the e2e harness has closed stdin.
|
||
*/
|
||
unsupportedPmRanges?: Partial<Record<string, string>>;
|
||
flags?: string[];
|
||
extraEnv?: Record<string, string | undefined>;
|
||
};
|
||
|
||
const packageManager = detectPackageManager();
|
||
|
||
export async function runC3ForFrameworkTest(
|
||
expect: ExpectStatic,
|
||
framework: string,
|
||
projectPath: string,
|
||
logStream: Writable,
|
||
{
|
||
argv = [],
|
||
promptHandlers = [],
|
||
extraEnv,
|
||
}: Pick<FrameworkTestConfig, "argv" | "promptHandlers" | "extraEnv">
|
||
) {
|
||
const args = [
|
||
projectPath,
|
||
"--type",
|
||
"web-framework",
|
||
"--framework",
|
||
framework,
|
||
"--deploy",
|
||
`${runDeployTests}`,
|
||
"--no-open",
|
||
"--no-auto-update",
|
||
...argv,
|
||
];
|
||
|
||
const { output } = await runC3(args, promptHandlers, logStream, extraEnv);
|
||
if (!runDeployTests) {
|
||
return null;
|
||
}
|
||
|
||
const deployedUrlRe =
|
||
/deployment is ready at: (https:\/\/.+?\.(pages|workers)\.dev)/;
|
||
|
||
const match = output.replaceAll("\n", "").match(deployedUrlRe);
|
||
if (!match || !match[1]) {
|
||
// eslint-disable-next-line no-console -- we dump raw C3 output for tests debugging
|
||
console.error(output);
|
||
expect(false, "Couldn't find deployment url in C3 output").toBe(true);
|
||
return "";
|
||
}
|
||
|
||
return match[1];
|
||
}
|
||
|
||
export function updateWranglerConfig(
|
||
projectPath: string,
|
||
handleUpdate: <T extends Record<string, unknown>>(config: T) => T
|
||
) {
|
||
const wranglerTomlPath = join(projectPath, "wrangler.toml");
|
||
const wranglerJsoncPath = join(projectPath, "wrangler.jsonc");
|
||
|
||
if (existsSync(wranglerTomlPath)) {
|
||
const wranglerToml = readToml(wranglerTomlPath);
|
||
|
||
writeToml(
|
||
wranglerTomlPath,
|
||
handleUpdate(JSON.parse(JSON.stringify(wranglerToml)))
|
||
);
|
||
|
||
return () => {
|
||
writeToml(wranglerTomlPath, handleUpdate(wranglerToml));
|
||
};
|
||
} else if (existsSync(wranglerJsoncPath)) {
|
||
const wranglerJsonc = readJSON(wranglerJsoncPath) as {
|
||
vars: Record<string, string>;
|
||
};
|
||
|
||
writeJSON(
|
||
wranglerJsoncPath,
|
||
handleUpdate(JSON.parse(JSON.stringify(wranglerJsonc)))
|
||
);
|
||
|
||
return () => {
|
||
writeJSON(wranglerJsoncPath, wranglerJsonc);
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Either update or create a wrangler configuration file to include a `TEST` var.
|
||
*
|
||
* This is rather than having a wrangler configuration file in the e2e test's fixture folder,
|
||
* which overwrites any that comes from the framework's template.
|
||
*/
|
||
export async function addTestVarsToWranglerToml(projectPath: string) {
|
||
updateWranglerConfig(projectPath, (config) => {
|
||
return {
|
||
...config,
|
||
vars: {
|
||
...(config.vars as Record<string, unknown>),
|
||
TEST: "C3_TEST",
|
||
},
|
||
};
|
||
});
|
||
}
|
||
|
||
export async function verifyDeployment(
|
||
expect: ExpectStatic,
|
||
{ testCommitMessage }: FrameworkTestConfig,
|
||
frameworkId: string,
|
||
projectName: string,
|
||
deploymentUrl: string,
|
||
expectedText: string
|
||
) {
|
||
if (!runDeployTests) {
|
||
return;
|
||
}
|
||
|
||
if (testCommitMessage && process.env.CLOUDFLARE_API_TOKEN) {
|
||
await testDeploymentCommitMessage(expect, projectName, frameworkId);
|
||
}
|
||
|
||
await retry({ times: 5 }, async () => {
|
||
await setTimeout(1_000);
|
||
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}"`
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
export async function verifyDevScript(
|
||
expect: ExpectStatic,
|
||
{ verifyDev }: FrameworkTestConfig,
|
||
{ devScript }: TemplateConfig,
|
||
projectPath: string,
|
||
logStream: Writable
|
||
) {
|
||
if (!verifyDev) {
|
||
return;
|
||
}
|
||
|
||
assert(
|
||
devScript,
|
||
"Expected a dev script as we are verifying the dev session in " +
|
||
projectPath
|
||
);
|
||
|
||
// Run the dev-server on random ports to avoid colliding with other tests
|
||
const port = await getPort();
|
||
const proc = spawnWithLogging(
|
||
[
|
||
packageManager.name,
|
||
"run",
|
||
devScript,
|
||
...(packageManager.name === "npm" ? ["--"] : []),
|
||
...(verifyDev.devArgs ?? []),
|
||
"--port",
|
||
`${port}`,
|
||
],
|
||
{
|
||
cwd: projectPath,
|
||
env: {
|
||
VITEST: undefined,
|
||
},
|
||
},
|
||
logStream
|
||
);
|
||
|
||
let restoreConfig: (() => void) | undefined;
|
||
|
||
try {
|
||
await retry(
|
||
{ times: 300, sleepMs: 5_000 },
|
||
async () => await fetch(`http://127.0.0.1:${port}${verifyDev.route}`)
|
||
);
|
||
|
||
// Make a request to the specified test route
|
||
const res = await fetch(`http://127.0.0.1:${port}${verifyDev.route}`);
|
||
expect(await res.text()).toContain(verifyDev.expectedText);
|
||
|
||
if (verifyDev.configChanges) {
|
||
const { configChanges } = verifyDev;
|
||
const updatedVars = configChanges.vars;
|
||
|
||
restoreConfig = updateWranglerConfig(projectPath, (config) => ({
|
||
...config,
|
||
vars: {
|
||
...(config.vars as Record<string, unknown>),
|
||
...updatedVars,
|
||
},
|
||
}));
|
||
|
||
await retry({ times: 10, sleepMs: 500 }, async () => {
|
||
const res2 = await fetch(`http://127.0.0.1:${port}${verifyDev.route}`);
|
||
expect(await res2.text()).toContain(configChanges.expectedText);
|
||
});
|
||
}
|
||
} finally {
|
||
// Kill the process gracefully so ports can be cleaned up
|
||
await kill(proc);
|
||
// Restore the wrangler config if we modified it
|
||
restoreConfig?.();
|
||
// Wait for a second to allow process to exit cleanly. Otherwise, the port might
|
||
// end up camped and cause future runs to fail
|
||
await setTimeout(1_000);
|
||
}
|
||
}
|
||
|
||
export async function verifyPreviewScript(
|
||
expect: ExpectStatic,
|
||
{ verifyPreview }: FrameworkTestConfig,
|
||
{ previewScript }: TemplateConfig,
|
||
projectPath: string,
|
||
logStream: Writable
|
||
) {
|
||
if (!verifyPreview) {
|
||
return;
|
||
}
|
||
|
||
assert(
|
||
previewScript,
|
||
"Expected a preview script is we are verifying the preview in " +
|
||
projectPath
|
||
);
|
||
if (verifyPreview.generateTypes) {
|
||
await runCommand([packageManager.name, "exec", "wrangler", "types"], {
|
||
cwd: projectPath,
|
||
});
|
||
}
|
||
|
||
if (verifyPreview.build) {
|
||
await runCommand([packageManager.name, "run", "build"], {
|
||
cwd: projectPath,
|
||
});
|
||
}
|
||
|
||
// Run the dev-server on random ports to avoid colliding with other tests
|
||
const port = await getPort();
|
||
|
||
const proc = spawnWithLogging(
|
||
[
|
||
packageManager.name,
|
||
"run",
|
||
previewScript,
|
||
...(packageManager.name === "npm" ? ["--"] : []),
|
||
...(verifyPreview.previewArgs ?? []),
|
||
"--port",
|
||
`${port}`,
|
||
],
|
||
{
|
||
cwd: projectPath,
|
||
env: {
|
||
VITEST: undefined,
|
||
// Make sure we're not running frameworks with NODE_ENV: test, as that causes strange behaviour
|
||
NODE_ENV: "production",
|
||
},
|
||
},
|
||
logStream
|
||
);
|
||
|
||
try {
|
||
// Some frameworks take quite a long time to build the application (e.g. Docusaurus)
|
||
// so wait some time for the dev-server to be ready.
|
||
await retry(
|
||
{ times: 60, sleepMs: 5_000 },
|
||
async () => await fetch(`http://localhost:${port}${verifyPreview.route}`)
|
||
);
|
||
|
||
// Make a request to the specified test route
|
||
const res = await fetch(`http://localhost:${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 setTimeout(1_000);
|
||
}
|
||
}
|
||
|
||
export async function verifyTypes(
|
||
expect: ExpectStatic,
|
||
{ nodeCompat, verifyTypes: verify }: FrameworkTestConfig,
|
||
{
|
||
workersTypes,
|
||
typesPath = "./worker-configuration.d.ts",
|
||
envInterfaceName = "Env",
|
||
}: TemplateConfig,
|
||
projectPath: string
|
||
) {
|
||
if (workersTypes === "none" || verify === false) {
|
||
return;
|
||
}
|
||
|
||
const outputFileContent = readFile(join(projectPath, typesPath)).split("\n");
|
||
|
||
const hasEnvInterface = outputFileContent.some(
|
||
(line) =>
|
||
// old type gen - some framework templates pin older versions of wrangler
|
||
line === `interface ${envInterfaceName} {` ||
|
||
line === `interface ${envInterfaceName} extends Cloudflare.Env {}` ||
|
||
// new after importable env change
|
||
line ===
|
||
`interface ${envInterfaceName} extends __BaseEnv_${envInterfaceName} {}`
|
||
);
|
||
expect(hasEnvInterface).toBe(true);
|
||
|
||
// if the runtime types were installed, they wont be in this file
|
||
if (workersTypes === "generated") {
|
||
expect(outputFileContent[2]).match(
|
||
/\/\/ Runtime types generated with workerd@1\.\d{8}\.\d \d{4}-\d{2}-\d{2} ([a-z_]+,?)*/
|
||
);
|
||
}
|
||
|
||
const tsconfigPath = join(projectPath, "tsconfig.json");
|
||
const tsconfig = jsonc.parse(readFile(tsconfigPath));
|
||
|
||
// Skip tsconfig verification if project uses TypeScript project references
|
||
// C3 doesn't modify the root tsconfig in this case - types are defined in child tsconfigs
|
||
if (Array.isArray(tsconfig.references) && tsconfig.references.length > 0) {
|
||
return;
|
||
}
|
||
|
||
const tsconfigTypes: string[] = tsconfig.compilerOptions?.types ?? [];
|
||
if (workersTypes === "generated") {
|
||
expect(tsconfigTypes).toContain(typesPath);
|
||
}
|
||
if (workersTypes === "installed") {
|
||
expect(
|
||
tsconfigTypes.some((x) => x.includes("@cloudflare/workers-types"))
|
||
).toBe(true);
|
||
}
|
||
if (nodeCompat) {
|
||
expect(tsconfigTypes).toContain(`node`);
|
||
}
|
||
}
|
||
|
||
export async function verifyCloudflareVitePluginConfigured(
|
||
expect: ExpectStatic,
|
||
{ verifyCloudflareVitePluginConfigured: verify }: FrameworkTestConfig,
|
||
projectPath: string
|
||
) {
|
||
if (!verify) {
|
||
return;
|
||
}
|
||
|
||
const viteConfigTsPAth = join(projectPath, `vite.config.ts`);
|
||
const viteConfigJsPath = join(projectPath, `vite.config.js`);
|
||
|
||
let viteConfigPath: string;
|
||
|
||
if (existsSync(viteConfigTsPAth)) {
|
||
viteConfigPath = viteConfigTsPAth;
|
||
} else if (existsSync(viteConfigJsPath)) {
|
||
viteConfigPath = viteConfigJsPath;
|
||
} else {
|
||
throw new Error("Could not find Vite config file to modify");
|
||
}
|
||
|
||
const prePackageJson = JSON.parse(
|
||
readFile(join(projectPath, "package.json"))
|
||
) as { devDependencies: Record<string, string> };
|
||
|
||
expect(
|
||
prePackageJson.devDependencies?.["@cloudflare/vite-plugin"]
|
||
).not.toBeUndefined();
|
||
|
||
const viteConfig = readFile(viteConfigPath);
|
||
|
||
expect(viteConfig).toContain(
|
||
'import { cloudflare } from "@cloudflare/vite-plugin"'
|
||
);
|
||
expect(viteConfig).toMatch(/plugins:\s*?\[.*?cloudflare.*?]/);
|
||
}
|
||
|
||
export function shouldRunTest(testConfig: FrameworkTestConfig) {
|
||
return (
|
||
// Skip if the test is quarantined
|
||
testConfig.quarantine !== true &&
|
||
// Skip if the package manager is unsupported
|
||
!testConfig.unsupportedPms?.includes(testPackageManager) &&
|
||
// Skip if the OS is unsupported
|
||
!testConfig.unsupportedOSs?.includes(process.platform) &&
|
||
// Skip if the package-manager version falls inside an unsupported range
|
||
!isUnsupportedPmVersion(testConfig)
|
||
);
|
||
}
|
||
|
||
function isUnsupportedPmVersion(testConfig: FrameworkTestConfig): boolean {
|
||
const range = testConfig.unsupportedPmRanges?.[testPackageManager];
|
||
if (!range || !testPackageManagerVersion) {
|
||
return false;
|
||
}
|
||
// Coerce to handle suffixes like "11.5.1-canary"; unparseable → run test.
|
||
const coerced = semver.coerce(testPackageManagerVersion);
|
||
if (!coerced) {
|
||
return false;
|
||
}
|
||
return semver.satisfies(coerced, range);
|
||
}
|
||
|
||
/**
|
||
* Gets the framework config and test info given a `frameworkKey`.
|
||
*
|
||
* Some frameworks support both Pages and Workers platform variants.
|
||
* If so, then the test must specify the variant in its key, of the form
|
||
* `<frameworkId>:<"pages"|"workers">`.
|
||
*/
|
||
export function getFrameworkConfig(frameworkKey: string) {
|
||
const frameworkMap = getFrameworkMap({
|
||
experimental: isExperimental,
|
||
});
|
||
const [frameworkId, platformVariant] = frameworkKey.split(":");
|
||
if ("platformVariants" in frameworkMap[frameworkId]) {
|
||
assert(
|
||
platformVariant === "pages" || platformVariant === "workers",
|
||
`Missing or invalid platformVariant in "${frameworkKey}" test.\nPlease update the test maps to contain both "${frameworkId}:pages" and "${frameworkId}:workers" properties.`
|
||
);
|
||
assert(
|
||
"platformVariants" in frameworkMap[frameworkId],
|
||
`Expected platformVariants for "${frameworkId}" framework config.`
|
||
);
|
||
return frameworkMap[frameworkId].platformVariants[platformVariant];
|
||
} else {
|
||
assert(
|
||
platformVariant === undefined,
|
||
`Unexpected platform variant in test for ${frameworkId}`
|
||
);
|
||
return frameworkMap[frameworkId];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Test that C3 added a git commit with the correct message.
|
||
*/
|
||
export async function testGitCommitMessage(
|
||
expect: ExpectStatic,
|
||
projectName: string,
|
||
framework: string,
|
||
projectPath: string
|
||
) {
|
||
const commitMessage = await runCommand(["git", "log", "-1"], {
|
||
silent: true,
|
||
cwd: projectPath,
|
||
});
|
||
|
||
expect(commitMessage).toMatch(
|
||
/Initialize web application via create-cloudflare CLI/
|
||
);
|
||
expect(commitMessage).toContain(`C3 = create-cloudflare@${version}`);
|
||
expect(commitMessage).toContain(`project name = ${projectName}`);
|
||
expect(commitMessage).toContain(`framework = ${framework}`);
|
||
}
|
||
|
||
/**
|
||
* Test that we pushed the commit message to the deployment correctly.
|
||
*/
|
||
export async function testDeploymentCommitMessage(
|
||
expect: ExpectStatic,
|
||
projectName: string,
|
||
framework: string
|
||
) {
|
||
const projectLatestCommitMessage = await retry({ times: 5 }, async () => {
|
||
// Wait for 2 seconds between each attempt
|
||
await setTimeout(2_000);
|
||
// Note: we cannot simply run git and check the result since the commit can be part of the
|
||
// deployment even without git, so instead we fetch the deployment info from the pages api
|
||
const response = await fetch(
|
||
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
|
||
},
|
||
}
|
||
);
|
||
|
||
const result = (
|
||
(await response.json()) as {
|
||
result: {
|
||
name: string;
|
||
latest_deployment?: {
|
||
deployment_trigger: {
|
||
metadata?: {
|
||
commit_message: string;
|
||
};
|
||
};
|
||
};
|
||
}[];
|
||
}
|
||
).result;
|
||
|
||
const commitMessage = result.find((project) => project.name === projectName)
|
||
?.latest_deployment?.deployment_trigger?.metadata?.commit_message;
|
||
if (!commitMessage) {
|
||
throw new Error("Could not find deployment with name " + projectName);
|
||
}
|
||
return commitMessage;
|
||
});
|
||
|
||
expect(projectLatestCommitMessage).toMatch(
|
||
/Initialize web application via create-cloudflare CLI/
|
||
);
|
||
expect(projectLatestCommitMessage).toContain(
|
||
`C3 = create-cloudflare@${version}`
|
||
);
|
||
expect(projectLatestCommitMessage).toContain(`project name = ${projectName}`);
|
||
expect(projectLatestCommitMessage).toContain(`framework = ${framework}`);
|
||
}
|