Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:11 +08:00

251 lines
7.3 KiB
TypeScript

import { tmpdir } from "node:os";
import { stripAnsi } from "@cloudflare/cli-shared-helpers";
import { version } from "../../package.json";
import { keys } from "./constants";
import { spawnWithLogging, testEnv, waitForExit } from "./spawn";
import type { Writable } from "node:stream";
export type PromptHandler = {
matcher: RegExp;
input:
| string[]
| {
type: "text";
chunks: string[];
assertErrorMessage?: string;
}
| {
type: "select";
target: RegExp | string;
assertDefaultSelection?: string;
assertDescriptionText?: string;
};
};
/**
* Responder for "opportunistic" prompts that only show up under specific
* conditions (e.g. the pnpm 11 approve-builds confirmation). Fires at most
* once per run, independent of the ordered `promptHandlers` queue.
*/
type BackgroundResponder = {
matcher: RegExp;
keys: string[];
};
export type RunnerConfig = {
promptHandlers?: PromptHandler[];
argv?: string[];
quarantine?: boolean;
timeout?: number;
/**
* Specifies whether to assert the response from the specified route after deployment.
*/
verifyDeploy: null | {
route: string;
expectedText: string;
};
/**
* Specifies whether to run the preview script for the project and assert the response from the specified route.
*/
verifyPreview: null | {
previewArgs?: string[];
route: string;
expectedText: string;
build?: boolean;
generateTypes?: boolean;
};
/**
* Specifies whether to run the test script for the project and verify the exit code.
*/
verifyTest?: boolean;
/**
* Specifies whether to run the dev script for the project and verify the response from the specified route.
*/
verifyDev?: {
devArgs?: string[];
route: string;
expectedText: string;
configChanges?: {
vars: Record<string, string>;
expectedText: string;
};
};
/**
* Specifies whether to verify generated types for the project.
*/
verifyTypes?: boolean;
/**
* Verifies whether the Cloudflare Vite plugin has been installed and configured.
*/
verifyCloudflareVitePluginConfigured?: boolean;
};
export const runC3 = async (
argv: string[] = [],
promptHandlers: PromptHandler[] = [],
logStream: Writable,
extraEnv: Record<string, string | undefined> = {}
) => {
// We don't use the "test" package manager here (i.e. E2E_TEST_PM and E2E_TEST_PM_VERSION) because yarn 1.x doesn't actually provide a `dlx` version.
// And in any case, this first step just installs a temp copy of create-cloudflare and executes it.
// The point of `detectPackageManager()` is for delegating to framework tooling when generating a project correctly.
const cmd = ["pnpx", `create-cloudflare@${version}`, ...argv];
const proc = spawnWithLogging(
cmd,
{ env: { ...testEnv, ...extraEnv }, cwd: tmpdir() },
logStream
);
// Responders for prompts that may or may not appear. Note that a responder
// can only write to stdin while it's still open — `handlePrompt` closes
// stdin once the last ordered handler is consumed. Framework tests whose
// recovery prompt fires after that should opt out via `unsupportedPmRanges`.
const backgroundResponders: BackgroundResponder[] = [
{
matcher: /Run `pnpm approve-builds [^`]+` and retry the install\?/,
keys: [keys.enter],
},
];
const handledBackground = new WeakSet<BackgroundResponder>();
const onData = (data: string) => {
handleBackgroundPrompts(data);
handlePrompt(data);
};
const handleBackgroundPrompts = (data: string) => {
const text = stripAnsi(data.toString());
for (const responder of backgroundResponders) {
if (handledBackground.has(responder)) {
continue;
}
if (!responder.matcher.test(text)) {
continue;
}
handledBackground.add(responder);
for (const keystroke of responder.keys) {
if (proc.stdin.writable) {
proc.stdin.write(keystroke);
}
}
}
};
// Clone the prompt handlers so we can consume them destructively
promptHandlers = [...promptHandlers];
// When changing selection, stdout updates but onData may not include the question itself
// so we store the current PromptHandler if we have already matched the question
let currentSelectDialog: PromptHandler | undefined;
const handlePrompt = (data: string) => {
const text = stripAnsi(data.toString());
const lines = text.split("\n");
const currentDialog = currentSelectDialog ?? promptHandlers[0];
if (!currentDialog) {
return;
}
const matchesPrompt = lines.some((line) =>
currentDialog.matcher.test(line)
);
// if we don't match the current question and we haven't already matched it previously
if (!matchesPrompt && !currentSelectDialog) {
return;
}
if (Array.isArray(currentDialog.input)) {
// keyboard input prompt handler
currentDialog.input.forEach((keystroke) => {
proc.stdin.write(keystroke);
});
} else if (currentDialog.input.type === "text") {
// text prompt handler
const { assertErrorMessage, chunks } = currentDialog.input;
if (
assertErrorMessage !== undefined &&
!text.includes(assertErrorMessage)
) {
throw new Error(
`The error message does not match; Expected "${assertErrorMessage}" but found "${text}".`
);
}
chunks.forEach((keystroke) => {
proc.stdin.write(keystroke);
});
} else if (currentDialog.input.type === "select") {
// select prompt handler
// FirstFrame: The first onData call for the current select dialog
const isFirstFrameOfCurrentSelectDialog =
currentSelectDialog === undefined;
// Our select prompt options start with ○ / ◁ for unselected options and ● / ◀ for the current selection
const selectedOptionRegex = /^\u200a+(●|◀)\s/;
const currentSelection = lines
.find((line) => line.match(selectedOptionRegex))
?.replace(selectedOptionRegex, "")
.trim();
if (!currentSelection) {
// sometimes `lines` contain only the 'clear screen' ANSI codes and not the prompt options
return;
}
const { target, assertDefaultSelection, assertDescriptionText } =
currentDialog.input;
if (
isFirstFrameOfCurrentSelectDialog &&
assertDefaultSelection !== undefined &&
assertDefaultSelection !== currentSelection
) {
throw new Error(
`The default selection does not match; Expected "${assertDefaultSelection}" but found "${currentSelection}".`
);
}
const matchesSelectionTarget =
typeof target === "string"
? currentSelection.includes(target)
: target.test(currentSelection);
const description = text.replaceAll("\n", " ");
if (
matchesSelectionTarget &&
assertDescriptionText !== undefined &&
!description.includes(assertDescriptionText)
) {
throw new Error(
`The description does not match; Expected "${assertDescriptionText}" but found "${description}".`
);
}
if (matchesSelectionTarget) {
// matches selection, so hit enter
proc.stdin.write(keys.enter);
currentSelectDialog = undefined;
} else {
// target not selected, hit down and wait for stdout to update (onData will be called again)
proc.stdin.write(keys.down);
currentSelectDialog = currentDialog;
return;
}
}
// Consume the handler once we've used it
promptHandlers.shift();
// 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();
}
};
return waitForExit(proc, onData);
};