Refactor mockable functionalities (#17392)

This commit is contained in:
fisker Cheung authored and GitHub committed 2025-04-23 23:42:19 +08:00
1 parent 369817b61f
commit e7202d63e7
18 files changed
+280 -162

No files matched your search

+13 -11
View File
@@ -5,19 +5,16 @@ import * as prettier from "../index.js";
import { expandPatterns } from "./expand-patterns.js";
import findCacheFile from "./find-cache-file.js";
import FormatResultsCache from "./format-results-cache.js";
import isTTY from "./is-tty.js";
import mockable from "./mockable.js";
import getOptionsForFile from "./options/get-options-for-file.js";
import {
createIsIgnoredFunction,
createTwoFilesPatch,
errors,
mockable,
picocolors,
} from "./prettier-internal.js";
import { normalizeToPosix, statSafe } from "./utils.js";
const { writeFormattedFile, getTimestamp } = mockable;
function diff(a, b) {
return createTwoFilesPatch("", "", a, b, "", "", { context: 2 });
}
@@ -33,7 +30,7 @@ function handleError(context, filename, error, printedFilename, ignoreUnknown) {
error instanceof errors.UndefinedParserError;
if (printedFilename) {
// Can't test on CI, `isTTY()` is always false, see ./is-tty.js
// Can't test on CI, `isTTY` is always false, see comments in `formatFiles`
/* c8 ignore next 3 */
if ((context.argv.write || ignoreUnknown) && errorIsUndefinedParseError) {
printedFilename.clear();
@@ -193,11 +190,11 @@ async function format(context, input, opt) {
context.logger.debug(
`'${performanceTestFlag.name}' found, running formatWithCursor ${repeat} times.`,
);
const start = getTimestamp();
const start = mockable.getTimestamp();
for (let i = 0; i < repeat; ++i) {
await prettier.formatWithCursor(input, opt);
}
const averageMs = (getTimestamp() - start) / repeat;
const averageMs = (mockable.getTimestamp() - start) / repeat;
const results = {
repeat,
hz: 1000 / averageMs,
@@ -303,6 +300,11 @@ async function formatFiles(context) {
}
}
// Some CI pipelines incorrectly report process.stdout.isTTY status,
// which causes unwanted lines in the output. An additional check for isCI() helps.
// See https://github.com/prettier/prettier/issues/5801
const isTTY = mockable.isStreamTTY(process.stdout) && !mockable.isCI();
for await (const { error, filename, ignoreUnknown } of expandPatterns(
context,
)) {
@@ -331,7 +333,7 @@ async function formatFiles(context) {
const fileNameToDisplay = normalizeToPosix(path.relative(cwd, filename));
let printedFilename;
if (isTTY()) {
if (isTTY) {
printedFilename = context.logger.log(fileNameToDisplay, {
newline: false,
clearable: true,
@@ -363,7 +365,7 @@ async function formatFiles(context) {
continue;
}
const start = getTimestamp();
const start = mockable.getTimestamp();
const isCacheExists = formatResultsCache?.existsAvailableFormatResultsCache(
filename,
@@ -405,7 +407,7 @@ async function formatFiles(context) {
}
if (context.argv.write) {
const timeToDisplay = `${Math.round(getTimestamp() - start)}ms`;
const timeToDisplay = `${Math.round(mockable.getTimestamp() - start)}ms`;
// Don't write the file if it won't change in order not to invalidate
// mtime based caches.
if (isDifferent) {
@@ -414,7 +416,7 @@ async function formatFiles(context) {
}
try {
await writeFormattedFile(filename, output);
await mockable.writeFormattedFile(filename, output);
// Set cache if format succeeds
shouldSetCache = true;
+5 -1
View File
@@ -4,6 +4,7 @@ import logFileInfoOrDie from "./file-info.js";
import logResolvedConfigPathOrDie from "./find-config-path.js";
import { formatFiles, formatStdin } from "./format.js";
import createLogger from "./logger.js";
import mockable from "./mockable.js";
import { parseArgvWithoutPlugins } from "./options/parse-cli-arguments.js";
import printSupportInfo from "./print-support-info.js";
import { createDetailedUsage, createUsage } from "./usage.js";
@@ -88,7 +89,8 @@ async function main(context) {
const hasFilePatterns = context.filePatterns.length > 0;
const useStdin =
!hasFilePatterns && (!process.stdin.isTTY || context.argv.filepath);
!hasFilePatterns &&
(!mockable.isStreamTTY(process.stdin) || context.argv.filepath);
if (useStdin) {
if (context.argv.cache) {
@@ -109,3 +111,5 @@ async function main(context) {
}
export { run };
// Exposed for tests
export { mockable } from "./mockable.js";
-8
View File
@@ -1,8 +0,0 @@
import { mockable } from "./prettier-internal.js";
// Some CI pipelines incorrectly report process.stdout.isTTY status,
// which causes unwanted lines in the output. An additional check for isCI() helps.
// See https://github.com/prettier/prettier/issues/5801
export default function isTTY() {
return process.stdout.isTTY && !mockable.isCI();
}
+2 -27
View File
@@ -1,31 +1,6 @@
import readline from "node:readline";
import stripAnsi from "strip-ansi";
import wcwidth from "wcwidth.js";
import mockable from "./mockable.js";
import { picocolors } from "./prettier-internal.js";
const countLines = (stream, text) => {
const columns = stream.columns || 80;
let lineCount = 0;
for (const line of stripAnsi(text).split("\n")) {
lineCount += Math.max(1, Math.ceil(wcwidth(line) / columns));
}
return lineCount;
};
const clear = (stream, text) => () => {
const lineCount = countLines(stream, text);
for (let line = 0; line < lineCount; line++) {
/* c8 ignore next 3 */
if (line > 0) {
readline.moveCursor(stream, 0, -1);
}
readline.clearLine(stream, 0);
readline.cursorTo(stream, 0);
}
};
const emptyLogResult = { clear() {} };
function createLogger(logLevel = "log") {
return {
@@ -56,7 +31,7 @@ function createLogger(logLevel = "log") {
if (options.clearable) {
return {
clear: clear(stream, message),
clear: () => mockable.clearStreamText(stream, message),
};
}
};
+17
View File
@@ -0,0 +1,17 @@
import fs from "node:fs/promises";
import { performance } from "node:perf_hooks";
import { isCI } from "ci-info";
// @ts-expect-error
import { __internal as sharedWithCli } from "../index.js";
import clearStreamText from "./utilities/clear-stream-text.js";
const mockable = sharedWithCli.utils.createMockable({
clearStreamText,
getTimestamp: performance.now.bind(performance),
isCI: () => isCI,
isStreamTTY: (stream) => stream.isTTY,
writeFormattedFile: (file, data) => fs.writeFile(file, data),
});
export default mockable.mocked;
export { mockable };
-1
View File
@@ -12,7 +12,6 @@ export const {
vnopts,
fastGlob,
createTwoFilesPatch,
mockable,
picocolors,
leven,
} = sharedWithCli;
+28
View File
@@ -0,0 +1,28 @@
import readline from "node:readline";
import stripAnsi from "strip-ansi";
import wcwidth from "wcwidth.js";
const countLines = (stream, text) => {
const columns = stream.columns || 80;
let lineCount = 0;
for (const line of stripAnsi(text).split("\n")) {
lineCount += Math.max(1, Math.ceil(wcwidth(line) / columns));
}
return lineCount;
};
function clearStreamText(stream, text) {
const lineCount = countLines(stream, text);
for (let line = 0; line < lineCount; line++) {
/* c8 ignore next 3 */
if (line > 0) {
readline.moveCursor(stream, 0, -1);
}
readline.clearLine(stream, 0);
readline.cursorTo(stream, 0);
}
}
export default clearStreamText;
+5 -13
View File
@@ -1,16 +1,8 @@
import fs from "node:fs/promises";
import { performance } from "node:perf_hooks";
import { isCI } from "ci-info";
import createMockable from "../utils/create-mockable.js";
function writeFormattedFile(file, data) {
return fs.writeFile(file, data);
}
const mockable = {
const mockable = createMockable({
getPrettierConfigSearchStopDirectory: () => undefined,
isCI: () => isCI,
writeFormattedFile,
getTimestamp: performance.now.bind(performance),
};
});
export default mockable;
export default mockable.mocked;
export { mockable };
+4 -2
View File
@@ -12,7 +12,7 @@ import picocolors from "picocolors";
import * as vnopts from "vnopts";
import * as errors from "./common/errors.js";
import getFileInfoWithoutPlugins from "./common/get-file-info.js";
import mockable from "./common/mockable.js";
import { mockable } from "./common/mockable.js";
import {
clearCache as clearConfigCache,
resolveConfig,
@@ -31,6 +31,7 @@ import {
getSupportInfo as getSupportInfoWithoutPlugins,
normalizeOptionSettings,
} from "./main/support.js";
import createMockable from "./utils/create-mockable.js";
import { createIsIgnoredFunction } from "./utils/ignore.js";
import inferParserWithoutPlugins from "./utils/infer-parser.js";
import omit from "./utils/object-omit.js";
@@ -114,8 +115,8 @@ const sharedWithCli = {
leven,
utils: {
omit,
createMockable,
},
mockable,
};
const debugApis = {
@@ -124,6 +125,7 @@ const debugApis = {
formatDoc: withPlugins(core.formatDoc),
printToDoc: withPlugins(core.printToDoc),
printDocToString: withPlugins(core.printDocToString),
// Exposed for tests
mockable,
};
+42
View File
@@ -0,0 +1,42 @@
/**
@template {{ [key: string]: any }} T
@template {keyof T} K
@param {T} implementations
@returns {{
mocked: T,
implementations: T,
mockImplementation(functionality: K, implementation: T[K]): void,
mockImplementations(overrideImplementations: T): void,
mockRestore(): void,
}}
*/
function createMockable(implementations) {
const mocked = { ...implementations };
const mockImplementation = (functionality, implementation) => {
if (!Object.hasOwn(implementations, functionality)) {
throw new Error(`Unexpected mock '${functionality}'.`);
}
// @ts-expect-error -- ??
mocked[functionality] = implementation;
};
const mockImplementations = (overrideImplementations) => {
for (const [functionality, implementation] of Object.entries(
overrideImplementations,
)) {
mockImplementation(functionality, implementation);
}
};
const mockRestore = () => {
Object.assign(mocked, implementations);
};
return {
mocked,
implementations,
mockImplementation,
mockImplementations,
mockRestore,
};
}
export default createMockable;
@@ -15,7 +15,7 @@ exports[`Line breaking after filepath with errors 1`] = `
invalid-2.js
invalid-2.unknown
valid-1.js
[[called readline.clearLine(process.stdout)]]
[[Clear text(process.stdout): valid-1.js]]
function foo() {}",
"write": [],
}
@@ -36,8 +36,7 @@ exports[`Line breaking after filepath with errors 2`] = `
invalid-2.js
invalid-2.unknown
valid-1.js
[[called readline.clearLine(process.stdout)]]
",
[[Clear text(process.stdout): valid-1.js]]",
"write": [],
}
`;
@@ -58,7 +57,7 @@ invalid-1.js
invalid-2.js
invalid-2.unknown
valid-1.js
[[called readline.clearLine(process.stdout)]]
[[Clear text(process.stdout): valid-1.js]]
All matched files use Prettier code style!",
"write": [],
}
@@ -78,9 +77,9 @@ exports[`Line breaking after filepath with errors 4`] = `
"stdout": "invalid-1.js
invalid-2.js
invalid-2.unknown
[[called readline.clearLine(process.stdout)]]
[[Clear text(process.stdout): invalid-2.unknown]]
valid-1.js
[[called readline.clearLine(process.stdout)]]
[[Clear text(process.stdout): valid-1.js]]
valid-1.js 0ms (unchanged)",
"write": [],
}
@@ -5,7 +5,7 @@ exports[`no file diffs with --check + formatted file 1`] = `
"stderr": "",
"stdout": "Checking formatting...
formatted.js
[[called readline.clearLine(process.stdout)]]
[[Clear text(process.stdout): formatted.js]]
All matched files use Prettier code style!",
"write": [],
}
@@ -24,8 +24,7 @@ exports[`no file diffs with --list-different + formatted file 1`] = `
{
"stderr": "",
"stdout": "formatted.js
[[called readline.clearLine(process.stdout)]]
",
[[Clear text(process.stdout): formatted.js]]",
"write": [],
}
`;
@@ -44,8 +43,7 @@ exports[`output with --check + unformatted differs when piped 1`] = `
[warn] Code style issues fixed in the above file.",
"stdout": "Checking formatting...
unformatted.js
[[called readline.clearLine(process.stdout)]]
",
[[Clear text(process.stdout): unformatted.js]]",
"write": [
{
"content": "var x = 1;
@@ -75,7 +73,7 @@ exports[`output with --list-different + unformatted differs when piped 1`] = `
{
"stderr": "",
"stdout": "unformatted.js
[[called readline.clearLine(process.stdout)]]
[[Clear text(process.stdout): unformatted.js]]
unformatted.js",
"write": [
{
@@ -3,7 +3,7 @@
exports[`Line breaking after filepath with errors 1`] = `
{
"stdout": "ignored.js
[[called readline.clearLine(process.stdout)]]
[[Clear text(process.stdout): ignored.js]]
foo(
)",
}
@@ -12,7 +12,7 @@ foo(
exports[`Line breaking after filepath with errors 2`] = `
{
"stdout": "not-ignored.js
[[called readline.clearLine(process.stdout)]]
[[Clear text(process.stdout): not-ignored.js]]
foo();",
}
`;
+4 -2
View File
@@ -1,8 +1,10 @@
import fs from "node:fs";
import { prettierCli } from "../env.js";
import { prettierCliEntry } from "../env.js";
describe("CLI", () => {
test("CLI should be executable.", () => {
expect(() => fs.accessSync(prettierCli, fs.constants.X_OK)).not.toThrow();
expect(() =>
fs.accessSync(prettierCliEntry, fs.constants.X_OK),
).not.toThrow();
});
});
+9 -4
View File
@@ -1,7 +1,12 @@
import prettier from "../../config/prettier-entry.js";
import url from "node:url";
import { prettierCliMockableEntry } from "../env.js";
const { mockable } = prettier.__debug;
async function getCliMockable() {
const cli = await import(url.pathToFileURL(prettierCliMockableEntry));
return cli.mockable;
}
test("isCI", () => {
expect(typeof mockable.isCI()).toBe("boolean");
test("isCI", async () => {
const mockable = await getCliMockable();
expect(typeof mockable.implementations.isCI()).toBe("boolean");
});
+69 -38
View File
@@ -1,7 +1,10 @@
import path from "node:path";
import readline from "node:readline";
import url from "node:url";
import { prettierCli, prettierMainEntry } from "./env.js";
import {
prettierCliEntry,
prettierCliMockableEntry,
prettierMainEntry,
} from "./env.js";
const normalizeToPosix =
path.sep === "\\"
@@ -17,55 +20,83 @@ const replaceAll = (text, find, replacement) =>
? text.replaceAll(find, replacement)
: text.split(find).join(replacement);
async function run(options) {
readline.clearLine = (stream) => {
stream.write(
`\n[[called readline.clearLine(${
async function getApiMockable() {
const prettier = await import(url.pathToFileURL(prettierMainEntry));
return prettier.__debug.mockable;
}
async function getCliMockable() {
const cli = await import(url.pathToFileURL(prettierCliMockableEntry));
return cli.mockable;
}
async function mockImplementations(options) {
const [apiMockable, cliMockable] = await Promise.all([
getApiMockable(),
getCliMockable(),
]);
cliMockable.mockImplementations({
clearStreamText(stream, text) {
const streamName =
stream === process.stdout
? "process.stdout"
: stream === process.stderr
? "process.stderr"
: "unknown stream"
})]]\n`,
);
};
: "unknown stream";
stream.write(`\n[[Clear text(${streamName}): ${text}]]\n`);
},
// Time measure in format test
getTimestamp: () => 0,
isCI: () => Boolean(options.ci),
isStreamTTY: (stream) =>
stream === process.stdin
? Boolean(options.isTTY)
: stream === process.stdout
? Boolean(options.stdoutIsTTY)
: stream.isTTY,
// eslint-disable-next-line require-await
async writeFormattedFile(filename, content) {
filename = normalizeToPosix(path.relative(process.cwd(), filename));
if (
options.mockWriteFileErrors &&
hasOwn(options.mockWriteFileErrors, filename)
) {
throw new Error(
options.mockWriteFileErrors[filename] + " (mocked error)",
);
}
process.send({
type: "cli:write-file",
data: { filename, content },
});
},
});
process.stdin.isTTY = Boolean(options.isTTY);
process.stdout.isTTY = Boolean(options.stdoutIsTTY);
apiMockable.mockImplementations({
getPrettierConfigSearchStopDirectory: () =>
url.fileURLToPath(new URL("./cli", import.meta.url)),
});
}
const prettier = await import(url.pathToFileURL(prettierMainEntry));
const { mockable } = prettier.__debug;
async function run(options) {
await mockImplementations(options);
// Time measure in format test
mockable.getTimestamp = () => 0;
mockable.isCI = () => Boolean(options.ci);
mockable.getPrettierConfigSearchStopDirectory = () =>
url.fileURLToPath(new URL("./cli", import.meta.url));
// eslint-disable-next-line require-await
mockable.writeFormattedFile = async (filename, content) => {
filename = normalizeToPosix(path.relative(process.cwd(), filename));
if (
options.mockWriteFileErrors &&
hasOwn(options.mockWriteFileErrors, filename)
) {
throw new Error(
options.mockWriteFileErrors[filename] + " (mocked error)",
);
}
process.send({
action: "write-file",
data: { filename, content },
});
};
const { __promise: promise } = await import(url.pathToFileURL(prettierCli));
const { __promise: promise } = await import(
url.pathToFileURL(prettierCliEntry)
);
await promise;
}
process.once("message", async (data) => {
try {
await run(data);
} catch (error) {
// For easier debugging
// eslint-disable-next-line no-console
console.error(error);
process.send({ type: "worker:fault", error });
throw error;
} finally {
// On MacOS, if we exit too quick the stdio won't received on main thread
if (process.platform === "darwin") {
+12 -2
View File
@@ -6,7 +6,7 @@ const { __dirname, require } = createEsmUtils(import.meta);
const isProduction = process.env.NODE_ENV === "production";
const { PRETTIER_DIR } = process.env;
const { bin } = require(path.join(PRETTIER_DIR, "package.json"));
const prettierCli = path.join(
const prettierCliEntry = path.join(
PRETTIER_DIR,
typeof bin === "object" ? bin.prettier : bin,
);
@@ -17,4 +17,14 @@ const prettierMainEntry = isProduction
const projectRoot = path.join(__dirname, "../..");
export { isProduction, prettierCli, prettierMainEntry, projectRoot };
const prettierCliMockableEntry = isProduction
? path.join(PRETTIER_DIR, "./internal/legacy-cli.mjs")
: path.join(PRETTIER_DIR, "./src/cli/index.js");
export {
isProduction,
prettierCliEntry,
prettierCliMockableEntry,
prettierMainEntry,
projectRoot,
};
+59 -39
View File
@@ -26,6 +26,16 @@ const removeFinalNewLine = (string) =>
string.endsWith("\n") ? string.slice(0, -1) : string;
const SUPPORTS_DISABLE_WARNING_FLAG =
Number(process.versions.node.split(".")[0]) >= 20;
const promiseWithResolvers = Promise.withResolvers
? Promise.withResolvers.bind(Promise)
: () => {
let resolve;
let reject;
const promise = new Promise(
(_resolve, _reject) => ([resolve, reject] = [_resolve, _reject]),
);
return { promise, resolve, reject };
};
/**
* @param {string} dir
@@ -39,6 +49,30 @@ function runCliWorker(dir, args, options) {
stderr: "",
write: [],
};
const { promise, resolve, reject } = promiseWithResolvers();
const createEpipeErrorHandler = (event) => (error) => {
if (!error) {
return;
}
// It can fail with `write EPIPE` error when running node with unsupported flags like `--experimental-strip-types`
// Let's ignore and wait for the `close` event
if (
error.code === "EPIPE" &&
error.syscall === "write" &&
nodeOptions.length > 0
) {
if (IS_CI) {
// eslint-disable-next-line no-console
console.error(
Object.assign(error, { event, dir, args, options, worker }),
);
}
return;
}
reject(error);
};
const nodeOptions = options?.nodeOptions ?? [];
const worker = childProcess.fork(CLI_WORKER_FILE, args, {
@@ -55,11 +89,14 @@ function runCliWorker(dir, args, options) {
...process.env,
NO_COLOR: "1",
},
serialization: "advanced",
});
worker.on("message", ({ action, data }) => {
if (action === "write-file") {
result.write.push(data);
worker.on("message", (message) => {
if (message.type === "cli:write-file") {
result.write.push(message.data);
} else if (message.type === "worker:fault") {
reject(message.error);
}
});
@@ -69,49 +106,32 @@ function runCliWorker(dir, args, options) {
});
}
if (options.input) {
worker.stdin.end(options.input);
}
const removeStdioFinalNewLine = () => {
for (const stream of ["stdout", "stderr"]) {
result[stream] = removeFinalNewLine(result[stream]);
}
};
return new Promise((resolve, reject) => {
worker.once("close", (code) => {
result.status = code;
removeStdioFinalNewLine();
resolve(result);
});
worker.once("error", (error) => {
reject(error);
});
worker.send(options, (error) => {
if (!error) {
return;
}
// It can fail with `write EPIPE` error when running node with unsupported flags like `--experimental-strip-types`
// Let's ignore and wait for the `close` event
if (
error.code === "EPIPE" &&
error.syscall === "write" &&
nodeOptions.length > 0
) {
if (IS_CI) {
// eslint-disable-next-line no-console
console.error(Object.assign(error, { dir, args, options, worker }));
}
return;
}
reject(error);
});
worker.once("close", (code) => {
result.status = code;
removeStdioFinalNewLine();
resolve(result);
});
worker.once("error", (error) => {
reject(error);
});
worker.once("spawn", () => {
if (options.input) {
worker.stdin.once("error", createEpipeErrorHandler("worker.stdin.end()"));
worker.stdin.end(options.input);
}
worker.send(options, createEpipeErrorHandler("worker.send()"));
});
return promise;
}
function runPrettierCli(dir, args, options) {