diff --git a/.changeset/validation-asset-hardening.md b/.changeset/validation-asset-hardening.md new file mode 100644 index 0000000..09a204b --- /dev/null +++ b/.changeset/validation-asset-hardening.md @@ -0,0 +1,9 @@ +--- +"vitest-native": patch +--- + +Three hardening fixes: + +- **Prerelease peer versions no longer fail validation.** A prerelease sharing the minimum's major.minor (e.g. vitest `4.0.0-beta.3` against the `4.0.0` floor) parsed with `NaN` in the patch slot, failed the minimum check, and hard-errored at startup for installs running betas/RCs. Prerelease/build metadata is now stripped before comparison; a prerelease of the minimum itself is accepted. +- **Mock-engine asset stubs match the native loader's semantics.** The extension match is now case-insensitive (`LOGO.PNG` stubs like `logo.png`), user-supplied `assetExts` entries are regex-escaped, and the stubbed basename is JSON-stringified so filenames containing quotes emit valid JS. +- **The mock engine's Flow-strip transform skips unparseable files instead of throwing.** The `@flow` filter is a heuristic — the marker can appear inside a string or comment of a file `flow-remove-types` then fails to parse; that parse error previously took down the whole transform pipeline. diff --git a/packages/vitest-native/src/plugin.ts b/packages/vitest-native/src/plugin.ts index f82c8d8..ca224aa 100644 --- a/packages/vitest-native/src/plugin.ts +++ b/packages/vitest-native/src/plugin.ts @@ -608,8 +608,12 @@ export function reactNative(options?: VitestNativeOptions): Plugin { } } - // Build the asset regex from the resolved extensions list. - assetPattern = new RegExp(`\\.(${resolved.assetExts.join("|")})$`); + // Build the asset regex from the resolved extensions list. Extensions are + // escaped (user-supplied entries may contain regex metacharacters) and the + // match is case-insensitive ("LOGO.PNG" is an asset too — the native + // loader already lowercases; the engines must agree). + const escaped = resolved.assetExts.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + assetPattern = new RegExp(`\\.(${escaped.join("|")})$`, "i"); }, resolveId(source, importer) { @@ -724,10 +728,13 @@ export function reactNative(options?: VitestNativeOptions): Plugin { } // Asset imports have the same stable semantics under both engines. + // JSON.stringify the basename — filenames can contain quotes/backslashes, + // and raw interpolation would emit broken JS (same hazard class as the + // native loader, which already stringifies). const fsPath = stripFsPrefix(id); if (assetPattern.test(fsPath)) { const basename = fsPath.split("/").pop() ?? fsPath; - return `export default "${basename}";`; + return `export default ${JSON.stringify(basename)};`; } // Native engine serves RN from Node's CJS graph — nothing else to load here. @@ -782,11 +789,25 @@ export function reactNative(options?: VitestNativeOptions): Plugin { if (!id.includes("react-native") || !id.endsWith(".js")) return undefined; if (!code.includes("@flow")) return undefined; - const stripped = flowRemoveTypes(code, { all: true }); - return { - code: stripped.toString(), - map: stripped.generateMap(), - }; + // The filters above are heuristics — "@flow" can appear inside a string + // or comment of a perfectly valid non-Flow file that flowRemoveTypes + // then fails to parse. Skipping is strictly better than throwing: a + // genuine Flow file that fails here would fail Vite's own parse next + // with a clearer error, while a false positive passes through untouched. + try { + const stripped = flowRemoveTypes(code, { all: true }); + return { + code: stripped.toString(), + map: stripped.generateMap(), + }; + } catch (e) { + if (diagnostics) { + console.warn( + `[vitest-native] Flow strip skipped for ${id} (parse failed: ${(e as Error)?.message}); serving the file untouched.`, + ); + } + return undefined; + } }, }; } diff --git a/packages/vitest-native/src/validate.ts b/packages/vitest-native/src/validate.ts index d5e9934..5a135da 100644 --- a/packages/vitest-native/src/validate.ts +++ b/packages/vitest-native/src/validate.ts @@ -83,9 +83,18 @@ export function validateOptions(options: Record): void { } function satisfiesMinimum(version: string, minimum: string): boolean { + // Strip prerelease/build metadata before splitting — "4.0.0-beta.3" split + // on "." as ["4","0","0-beta","3"] put NaN in the PATCH slot, so a + // prerelease sharing the minimum's major.minor (e.g. vitest 4.0.0-beta.x + // against the 4.0.0 floor) failed the check and hard-errored at startup — + // exactly the early-adopter installs that run betas. A prerelease of the + // minimum itself is accepted (deliberate: rejecting it would re-break that + // cohort; vite/vitest don't publish patch-level prereleases, so the + // security-floor bypass this theoretically allows doesn't occur on npm). const parse = (v: string) => v .replace(/^[^0-9]*/, "") + .split(/[-+]/)[0] .split(".") .map(Number); const [aMaj, aMin = 0, aPat = 0] = parse(version); diff --git a/packages/vitest-native/tests/plugin-hardening.test.ts b/packages/vitest-native/tests/plugin-hardening.test.ts new file mode 100644 index 0000000..bd2271b --- /dev/null +++ b/packages/vitest-native/tests/plugin-hardening.test.ts @@ -0,0 +1,76 @@ +/** + * Hardening regressions for the plugin's asset stubbing and Flow-strip + * transform (mock engine). + */ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { reactNative } from "../src/index.js"; +import { gestureHandler } from "../src/presets/index.js"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +function findUp(rel: string, start: string): string { + let dir = start; + for (;;) { + const candidate = path.join(dir, rel); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) throw new Error(`${rel} not found from ${start}`); + dir = parent; + } +} +const projectRoot = path.dirname(findUp("package.json", HERE)); +const SERVE_ENV = { command: "serve", mode: "test" } as const; + +async function makePlugin() { + const plugin = reactNative({ engine: "mock", presets: [gestureHandler()] }) as any; + await plugin.config({ root: projectRoot }, SERVE_ENV); + await plugin.configResolved({ root: projectRoot }); + return plugin; +} + +describe("asset stubbing (mock engine)", () => { + it("matches asset extensions case-insensitively, like the native loader", async () => { + const plugin = await makePlugin(); + expect(plugin.load("/proj/assets/LOGO.PNG")).toBe(`export default "LOGO.PNG";`); + expect(plugin.load("/proj/assets/Icon.TTF")).toBe(`export default "Icon.TTF";`); + }); + + it("emits valid JS for basenames containing quotes", async () => { + const plugin = await makePlugin(); + const code = plugin.load(`/proj/assets/we"ird.png`); + // Assert the PROPERTY (the emitted literal round-trips to the basename), + // not the mechanism — raw interpolation would emit a syntax error here. + const literal = code.replace(/^export default /, "").replace(/;$/, ""); + expect(JSON.parse(literal)).toBe(`we"ird.png`); + }); +}); + +describe("Flow-strip transform guard (mock engine)", () => { + it("still strips genuine Flow sources in react-native ecosystem packages", async () => { + const plugin = await makePlugin(); + const result = plugin.transform( + `// @flow\ntype Props = { x: number };\nmodule.exports = function f(p: Props) { return p.x; };`, + "/proj/node_modules/react-native-thing/lib/f.js", + ); + expect(result).toBeTruthy(); + expect(result.code).not.toContain("Props"); + expect(result.code).toContain("return p.x"); + }); + + it("skips files the stripper cannot parse instead of throwing", async () => { + const plugin = await makePlugin(); + // "@flow" appears in a string of a file that is not valid input for the + // stripper — previously this threw and took down the whole transform + // pipeline; now it passes through untouched. + let result: unknown = "not called"; + expect(() => { + result = plugin.transform( + `const marker = "@flow"; const = broken;`, + "/proj/node_modules/react-native-thing/lib/broken.js", + ); + }).not.toThrow(); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/vitest-native/tests/validation.test.ts b/packages/vitest-native/tests/validation.test.ts index bd73a5e..d6b54e8 100644 --- a/packages/vitest-native/tests/validation.test.ts +++ b/packages/vitest-native/tests/validation.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { validatePeerDependency } from "../src/validate.js"; import { reactNative } from "../src/index.js"; @@ -26,6 +29,52 @@ describe("validatePeerDependency", () => { }); expect(result).toBeNull(); }); + + // Prerelease versions previously parsed to NaN ("4.0.0-beta.3" → [4, NaN, …]) + // and failed the minimum check — a hard startup error for exactly the + // early-adopter installs that run betas/RCs. + describe("prerelease versions", () => { + function withFixture(version: string, fn: (root: string) => void): void { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vn-prerelease-")); + try { + const pkgDir = path.join(tmp, "node_modules", "fixture-pkg"); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(path.join(tmp, "package.json"), JSON.stringify({ name: "consumer" })); + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ name: "fixture-pkg", version, main: "index.js" }), + ); + fs.writeFileSync(path.join(pkgDir, "index.js"), "module.exports = {};"); + fn(tmp); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + } + + it("accepts a prerelease of a version above the minimum", () => { + withFixture("4.1.0-beta.3", (root) => { + expect(validatePeerDependency("fixture-pkg", "4.0.0", root)).toBeNull(); + }); + }); + + it("accepts a prerelease of the minimum itself", () => { + withFixture("4.0.0-beta.3", (root) => { + expect(validatePeerDependency("fixture-pkg", "4.0.0", root)).toBeNull(); + }); + }); + + it("still rejects a prerelease below the minimum", () => { + withFixture("3.9.0-rc.1", (root) => { + expect(validatePeerDependency("fixture-pkg", "4.0.0", root)).toContain("requires"); + }); + }); + + it("still rejects a prerelease of the excluded next major", () => { + withFixture("5.0.0-alpha.1", (root) => { + expect(validatePeerDependency("fixture-pkg", "4.0.0", root, 5)).toContain("supports"); + }); + }); + }); }); describe("engine option", () => {