Files
cloudflare__vinext/tests/postcss-resolve.test.ts
James Anderson a84a038fe6 fix(config): share CommonJS module fallback (#1990)
* fix(config): share CommonJS module fallback

* fix(config): harden CommonJS retry detection
2026-06-13 00:13:22 +01:00

470 lines
17 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test";
import path from "node:path";
import os from "node:os";
describe("resolvePostcssStringPlugins", () => {
let resolvePostcssStringPlugins: (typeof import("../packages/vinext/src/plugins/postcss.js"))["resolvePostcssStringPlugins"];
beforeAll(async () => {
const mod = await import("../packages/vinext/src/plugins/postcss.js");
resolvePostcssStringPlugins = mod.resolvePostcssStringPlugins;
});
/**
* Creates a temporary project directory with a mock PostCSS plugin
* and a postcss config file. Returns the temp dir path.
*/
async function createTmpProject(
configFileName: string,
configContent: string,
opts?: { mockPluginContent?: string },
): Promise<string> {
const fsp = await import("node:fs/promises");
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-postcss-"));
// Create a mock PostCSS plugin that the config can reference
const pluginDir = path.join(dir, "node_modules", "mock-postcss-plugin");
await fsp.mkdir(pluginDir, { recursive: true });
const pluginContent =
opts?.mockPluginContent ??
`
module.exports = function mockPlugin(opts) {
return {
postcssPlugin: "mock-postcss-plugin",
Once(root) {},
};
};
module.exports.postcss = true;
`;
await fsp.writeFile(path.join(pluginDir, "index.js"), pluginContent);
await fsp.writeFile(
path.join(pluginDir, "package.json"),
JSON.stringify({ name: "mock-postcss-plugin", version: "1.0.0", main: "index.js" }),
);
// Write the PostCSS config file
await fsp.writeFile(path.join(dir, configFileName), configContent);
return dir;
}
async function cleanupDir(dir: string) {
const fsp = await import("node:fs/promises");
await fsp.rm(dir, { recursive: true, force: true }).catch(() => {});
}
// --- No config file ---
it("returns undefined when no PostCSS config exists", async () => {
const fsp = await import("node:fs/promises");
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-postcss-none-"));
try {
const result = await resolvePostcssStringPlugins(dir);
expect(result).toBeUndefined();
} finally {
await cleanupDir(dir);
}
});
// --- Array form with string plugin ---
it("resolves string plugin names in array-form postcss.config.cjs", async () => {
const dir = await createTmpProject(
"postcss.config.cjs",
`module.exports = { plugins: ["mock-postcss-plugin"] };`,
);
try {
const result = await resolvePostcssStringPlugins(dir);
expect(result).toBeDefined();
expect(result!.plugins).toHaveLength(1);
// The resolved plugin should be an object (PostCSS plugin instance)
const plugin = result!.plugins[0];
expect(plugin).toBeDefined();
expect(typeof plugin === "object" || typeof plugin === "function").toBe(true);
} finally {
await cleanupDir(dir);
}
});
it("resolves string plugin names in array-form postcss.config.mjs", async () => {
const dir = await createTmpProject(
"postcss.config.mjs",
`export default { plugins: ["mock-postcss-plugin"] };`,
);
try {
const result = await resolvePostcssStringPlugins(dir);
expect(result).toBeDefined();
expect(result!.plugins).toHaveLength(1);
} finally {
await cleanupDir(dir);
}
});
it("loads a CommonJS postcss.config.js in an ESM project", async () => {
const dir = await createTmpProject(
"postcss.config.js",
`const plugins = require("./plugins.js");
module.exports = { plugins };
`,
);
const fsp = await import("node:fs/promises");
await fsp.writeFile(path.join(dir, "package.json"), JSON.stringify({ type: "module" }));
await fsp.writeFile(path.join(dir, "plugins.js"), `module.exports = ["mock-postcss-plugin"];`);
try {
const result = await resolvePostcssStringPlugins(dir);
expect(result?.plugins).toHaveLength(1);
expect(result?.plugins[0]).toHaveProperty("postcssPlugin", "mock-postcss-plugin");
} finally {
await cleanupDir(dir);
}
});
// --- Object form (should be skipped) ---
it("returns undefined for object-form plugins (postcss-load-config handles these)", async () => {
const dir = await createTmpProject(
"postcss.config.cjs",
`module.exports = { plugins: { "mock-postcss-plugin": {} } };`,
);
try {
const result = await resolvePostcssStringPlugins(dir);
expect(result).toBeUndefined();
} finally {
await cleanupDir(dir);
}
});
// --- Array form without string entries (already resolved) ---
it("returns undefined when array plugins contain no strings", async () => {
const dir = await createTmpProject(
"postcss.config.cjs",
`const plugin = require("mock-postcss-plugin");
module.exports = { plugins: [plugin()] };`,
);
try {
const result = await resolvePostcssStringPlugins(dir);
expect(result).toBeUndefined();
} finally {
await cleanupDir(dir);
}
});
// --- Array tuple form: ["plugin-name", { options }] ---
it("resolves array tuple form [name, options]", async () => {
const dir = await createTmpProject(
"postcss.config.cjs",
`module.exports = { plugins: [["mock-postcss-plugin", { foo: "bar" }]] };`,
);
try {
const result = await resolvePostcssStringPlugins(dir);
expect(result).toBeDefined();
expect(result!.plugins).toHaveLength(1);
} finally {
await cleanupDir(dir);
}
});
// --- Mixed array with strings and already-resolved plugins ---
it("handles mixed array with strings and pre-resolved plugins", async () => {
const dir = await createTmpProject(
"postcss.config.cjs",
`const plugin = require("mock-postcss-plugin");
module.exports = { plugins: ["mock-postcss-plugin", plugin()] };`,
);
try {
const result = await resolvePostcssStringPlugins(dir);
expect(result).toBeDefined();
expect(result!.plugins).toHaveLength(2);
} finally {
await cleanupDir(dir);
}
});
// --- Plugin that exports a default function ---
it("calls default export function for string plugin", async () => {
const dir = await createTmpProject(
"postcss.config.cjs",
`module.exports = { plugins: ["mock-postcss-plugin"] };`,
{
mockPluginContent: `
module.exports = function(opts) {
return {
postcssPlugin: "mock-plugin",
Once(root) {},
};
};
module.exports.postcss = true;
`,
},
);
try {
const result = await resolvePostcssStringPlugins(dir);
expect(result).toBeDefined();
expect(result!.plugins).toHaveLength(1);
// Should be the result of calling the function (a plugin object)
expect(result!.plugins[0]).toHaveProperty("postcssPlugin", "mock-plugin");
} finally {
await cleanupDir(dir);
}
});
it("loads CommonJS .js plugins from read-only symlink targets in an ESM monorepo", async () => {
const fsp = await import("node:fs/promises");
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-postcss-local-cjs-"));
const packageDir = path.join(dir, "packages", "local-plugin");
const pluginLink = path.join(dir, "node_modules", "local-plugin");
await fsp.mkdir(packageDir, { recursive: true });
await fsp.mkdir(path.dirname(pluginLink), { recursive: true });
await fsp.writeFile(path.join(dir, "package.json"), JSON.stringify({ type: "module" }));
await fsp.writeFile(
path.join(packageDir, "package.json"),
JSON.stringify({ name: "local-plugin", main: "plugin.js", type: "module" }),
);
await fsp.writeFile(path.join(packageDir, "name.js"), `module.exports = "local-cjs-plugin";`);
await fsp.writeFile(
path.join(packageDir, "plugin.js"),
`const name = require("./name.js");
const plugin = () => ({ postcssPlugin: name, Once() {} });
plugin.postcss = true;
module.exports = plugin;`,
);
await fsp.symlink(packageDir, pluginLink, "junction");
await fsp.writeFile(
path.join(dir, "postcss.config.cjs"),
`module.exports = { plugins: [require.resolve("local-plugin")] };`,
);
await fsp.chmod(packageDir, 0o555);
try {
const result = await resolvePostcssStringPlugins(dir);
expect(result?.plugins).toHaveLength(1);
expect(result?.plugins[0]).toHaveProperty("postcssPlugin", "local-cjs-plugin");
expect(
(await fsp.readdir(packageDir)).some((name) => name.startsWith(".vinext-postcss-")),
).toBe(false);
} finally {
await fsp.chmod(packageDir, 0o755).catch(() => {});
await cleanupDir(dir);
}
});
// --- Unresolvable string plugin must not crash the build ---
//
// Regression for #1509: a CSS-modules + PostCSS app (e.g. a config like
// `plugins: ["postcss-nested", ...]`) must not abort the build when a
// named plugin can't be resolved from the project root. This config hook
// runs once per Vite environment (RSC/SSR/client), so a throw here aborts
// the whole build with an opaque error. We fall back to `undefined` and let
// Vite's own postcss-load-config resolve the config relative to the config
// file instead.
it("returns undefined (does not throw) when a string plugin can't be resolved", async () => {
const dir = await createTmpProject(
"postcss.config.cjs",
// "missing-postcss-plugin" is not installed in this temp project.
`module.exports = { plugins: ["mock-postcss-plugin", "missing-postcss-plugin"] };`,
);
try {
const result = await resolvePostcssStringPlugins(dir);
expect(result).toBeUndefined();
} finally {
await cleanupDir(dir);
}
});
it("returns undefined when an unresolvable plugin appears in tuple form", async () => {
const dir = await createTmpProject(
"postcss.config.cjs",
`module.exports = { plugins: [["missing-postcss-plugin", { foo: "bar" }]] };`,
);
try {
const result = await resolvePostcssStringPlugins(dir);
expect(result).toBeUndefined();
} finally {
await cleanupDir(dir);
}
});
// --- JSON/YAML configs are skipped ---
it("returns undefined for .postcssrc.json (postcss-load-config handles these)", async () => {
const fsp = await import("node:fs/promises");
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-postcss-json-"));
await fsp.writeFile(
path.join(dir, ".postcssrc.json"),
JSON.stringify({ plugins: { autoprefixer: {} } }),
);
try {
const result = await resolvePostcssStringPlugins(dir);
expect(result).toBeUndefined();
} finally {
await cleanupDir(dir);
}
});
// --- Config file priority ---
it("picks postcss.config.js over .postcssrc", async () => {
const fsp = await import("node:fs/promises");
const dir = await createTmpProject(
"postcss.config.js",
`module.exports = { plugins: ["mock-postcss-plugin"] };`,
);
// Also create a .postcssrc with object form
await fsp.writeFile(
path.join(dir, ".postcssrc"),
JSON.stringify({ plugins: { autoprefixer: {} } }),
);
try {
const result = await resolvePostcssStringPlugins(dir);
// postcss.config.js has array-form with string — should resolve
expect(result).toBeDefined();
expect(result!.plugins).toHaveLength(1);
} finally {
await cleanupDir(dir);
}
});
});
// ---------------------------------------------------------------------------
// Integration test: Vite config hook injects resolved PostCSS
// ---------------------------------------------------------------------------
describe("PostCSS string plugin resolution in Vite config", () => {
let tmpDir: string;
afterAll(async () => {
if (tmpDir) {
const fsp = await import("node:fs/promises");
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
});
it("injects resolved PostCSS plugins into Vite css.postcss config", async () => {
const fsp = await import("node:fs/promises");
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-postcss-int-"));
// Symlink node_modules from project root
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction");
// Create a mock PostCSS plugin in node_modules
const pluginDir = path.join(tmpDir, "node_modules", "mock-postcss-plugin");
await fsp.mkdir(pluginDir, { recursive: true });
await fsp.writeFile(
path.join(pluginDir, "index.js"),
`module.exports = function(opts) {
return { postcssPlugin: "mock-postcss-plugin", Once(root) {} };
};
module.exports.postcss = true;`,
);
await fsp.writeFile(
path.join(pluginDir, "package.json"),
JSON.stringify({ name: "mock-postcss-plugin", version: "1.0.0", main: "index.js" }),
);
// PostCSS config with array-form string plugin
await fsp.writeFile(
path.join(tmpDir, "postcss.config.cjs"),
`module.exports = { plugins: ["mock-postcss-plugin"] };`,
);
// Minimal pages directory
await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
// Create a Vite server to test the resolved config
const { createServer } = await import("vite");
const vinext = (await import("../packages/vinext/src/index.js")).default;
const server = await createServer({
root: tmpDir,
configFile: false,
plugins: [vinext()],
server: { port: 0 },
logLevel: "silent",
});
try {
// Check the resolved config has css.postcss set
const postcssConfig = server.config.css?.postcss;
expect(postcssConfig).toBeDefined();
expect(typeof postcssConfig).toBe("object");
const postcssObj = postcssConfig as { plugins: any[] };
expect(postcssObj.plugins).toHaveLength(1);
expect(postcssObj.plugins[0]).toHaveProperty("postcssPlugin", "mock-postcss-plugin");
} finally {
await server.close();
}
}, 30000);
it("applies a local CommonJS PostCSS plugin to CSS and SCSS modules in an ESM app", async () => {
// Ported from Next.js: test/e2e/app-dir/css-modules-rsc-postcss/css-modules-rsc-postcss.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/css-modules-rsc-postcss/css-modules-rsc-postcss.test.ts
const fsp = await import("node:fs/promises");
const { build } = await import("vite");
const vinext = (await import("../packages/vinext/src/index.js")).default;
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-css-modules-rsc-postcss-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(dir, "node_modules"), "junction");
await fsp.writeFile(path.join(dir, "package.json"), JSON.stringify({ type: "module" }));
await fsp.writeFile(
path.join(dir, "plugin.js"),
`const plugin = () => ({
postcssPlugin: "color-change",
Declaration: { color(declaration) { declaration.value = "green"; } },
});
plugin.postcss = true;
module.exports = plugin;`,
);
await fsp.writeFile(
path.join(dir, "postcss.config.cjs"),
`module.exports = { plugins: [require.resolve("./plugin")] };`,
);
await fsp.writeFile(path.join(dir, "page.module.css"), `.main { color: red; }`);
await fsp.writeFile(path.join(dir, "other.module.scss"), `.other { color: red; }`);
await fsp.writeFile(
path.join(dir, "entry.js"),
`import styles from "./page.module.css";
import other from "./other.module.scss";
console.log(styles.main, other.other);`,
);
try {
await build({
root: dir,
configFile: false,
plugins: [vinext({ disableAppRouter: true })],
logLevel: "silent",
build: {
outDir: "dist",
rolldownOptions: { input: path.join(dir, "entry.js") },
},
});
const distDir = path.join(dir, "dist");
const entries = await fsp.readdir(distDir, { recursive: true, withFileTypes: true });
const cssEntries = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".css"));
expect(cssEntries.length).toBeGreaterThan(0);
const css = (
await Promise.all(
cssEntries.map((entry) => fsp.readFile(path.join(entry.parentPath, entry.name), "utf8")),
)
).join("\n");
expect(css).toContain("color:green");
expect(css).toMatch(/_main[_-]/);
expect(css).toMatch(/_other[_-]/);
expect(css).not.toContain("color:red");
} finally {
await fsp.rm(dir, { recursive: true, force: true }).catch(() => {});
}
}, 30000);
});