Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/validation-asset-hardening.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 29 additions & 8 deletions packages/vitest-native/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
},
};
}
9 changes: 9 additions & 0 deletions packages/vitest-native/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,18 @@ export function validateOptions(options: Record<string, unknown>): 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);
Expand Down
76 changes: 76 additions & 0 deletions packages/vitest-native/tests/plugin-hardening.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
49 changes: 49 additions & 0 deletions packages/vitest-native/tests/validation.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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", () => {
Expand Down
Loading