Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .github/workflows/e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@ jobs:
node-version: 24
command: |
vp test run
- name: vp-config
node-version: 22
command: |
vp check
vp pack
vp test
exclude:
# frm-stack uses Docker (testcontainers) which doesn't work the same way on Windows
- os: windows-latest
Expand Down
5 changes: 5 additions & 0 deletions ecosystem-ci/repo.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,10 @@
"branch": "main",
"hash": "451925ad7c07750a23de1d6ed454825d0eb14092",
"forceFreshMigration": true
},
"vp-config": {
"repository": "https://github.com/kazupon/vp-config.git",
"branch": "main",
"hash": "b58c48d71a17c25dec71a003535e6312791ce2aa"
}
}
51 changes: 50 additions & 1 deletion packages/cli/src/pack-bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,48 @@ import { cac } from 'cac';

import { resolveViteConfig } from './resolve-vite-config.js';

/**
* Rolldown plugin that transforms value imports/exports to type-only in external
* packages' .d.ts files. Some packages (e.g. postcss, lightningcss) use
* `import { X }` and `export { X } from` instead of their type-only equivalents,
* which causes MISSING_EXPORT warnings from the DTS bundler.
*
* Since .d.ts files contain only type information, all imports/exports are
* inherently type-only, so this transformation is always safe.
*/
const EXTERNAL_DTS_INTERNAL_RE = /node_modules\/(postcss|lightningcss)\/.*\.d\.(ts|mts|cts)$/;
// Match consumer .d.ts files that import from postcss/lightningcss.
// In CI (installed from tgz): node_modules/vite-plus-core/dist/...
// In local development (symlinked workspace): packages/core/dist/...
const EXTERNAL_DTS_CONSUMER_RE =
/(?:vite-plus-core|packages\/core)\/.*lightningcssOptions\.d\.ts$|(?:vite-plus-core|packages\/core)\/dist\/.*\.d\.ts$/;
const EXTERNAL_DTS_FIX_RE = new RegExp(
`${EXTERNAL_DTS_INTERNAL_RE.source}|${EXTERNAL_DTS_CONSUMER_RE.source}`,
Comment thread
fengmk2 marked this conversation as resolved.
);

function externalDtsTypeOnlyPlugin() {
return {
name: 'vite-plus:external-dts-type-only',
transform: {
filter: { id: { include: [EXTERNAL_DTS_FIX_RE] } },
handler(code: string, rawId: string) {
// Normalize Windows backslash paths to forward slashes for regex matching
const id = rawId.replaceAll('\\', '/');
if (EXTERNAL_DTS_INTERNAL_RE.test(id)) {
// postcss/lightningcss internal files: transform imports only
// (exports may include value re-exports like `export const Features`)
return code.replace(/^(import\s+)(?!type\s)/gm, 'import type ');
}
// Consumer files: only transform imports from postcss/lightningcss
return code.replace(
/^(import\s+)(?!type\s)(.+from\s+['"](?:postcss|lightningcss)['"])/gm,
'import type $2',
);
},
},
};
}

const cli = cac('vp pack');
cli.help();

Expand Down Expand Up @@ -98,7 +140,14 @@ cli
? viteConfig.pack
: [viteConfig.pack ?? {}];
for (const packConfig of packConfigs) {
const resolvedConfig = await resolveUserConfig({ ...packConfig, ...flags }, flags);
const merged = { ...packConfig, ...flags };
// Inject plugin to fix MISSING_EXPORT warnings from external .d.ts files
// (postcss, lightningcss use `import`/`export` instead of `import type`/`export type`)
if (merged.dts) {
const existingPlugins = Array.isArray(merged.plugins) ? merged.plugins : [];
merged.plugins = [...existingPlugins, externalDtsTypeOnlyPlugin()];
}
const resolvedConfig = await resolveUserConfig(merged, flags);
configs.push(...resolvedConfig);
}

Expand Down
187 changes: 168 additions & 19 deletions packages/test/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -931,8 +931,9 @@ async function rewriteVitestImports(leafDepToVendorPath: Map<string, string>) {
vitest: resolve(distDir, 'index.js'),
'vitest/node': resolve(distDir, 'node.js'),
'vitest/config': resolve(distDir, 'config.js'),
// vitest/browser exports page, server, etc from @vitest/browser
'vitest/browser': resolve(distDir, '@vitest/browser/index.js'),
// vitest/browser exports page, server, CDPSession, BrowserCommands, etc from @vitest/browser/context
// This matches vitest's own package.json exports: "./browser" -> "./browser/context.d.ts"
'vitest/browser': resolve(distDir, '@vitest/browser/context.js'),
// vitest/internal/browser exports browser-safe __INTERNAL and stringify (NOT @vitest/browser/index.js which has Node.js code)
'vitest/internal/browser': resolve(distDir, 'browser.js'),
'vitest/runners': resolve(distDir, 'runners.js'),
Expand Down Expand Up @@ -2064,7 +2065,7 @@ export * from '../dist/@vitest/browser/context.d.ts'
}

/**
* Patch module augmentations in global.d.*.d.ts files to use relative paths.
* Patch module augmentations in global.d.*.d.ts files.
*
* The original vitest types use module augmentation like:
* declare module "@vitest/expect" { interface Assertion<T> { toMatchSnapshot: ... } }
Expand All @@ -2073,12 +2074,11 @@ export * from '../dist/@vitest/browser/context.d.ts'
* "@vitest/expect" doesn't exist as a package for consumers. This breaks the
* module augmentation - TypeScript can't find @vitest/expect to augment.
*
* The fix: Change module augmentation to use relative paths that TypeScript CAN resolve:
* declare module "../@vitest/expect/index.js" { ... }
*
* This makes TypeScript augment the same module that our index.d.ts imports from,
* so the augmented properties (toMatchSnapshot, toMatchInlineSnapshot, etc.)
* appear on the Assertion type that consumers import.
* The fix has two parts:
* 1. Change module augmentation to use relative paths that TypeScript CAN resolve:
* declare module "../@vitest/expect/index.js" { ... }
* 2. Merge augmented interface/type definitions into the target .d.ts files so that
* downstream DTS bundlers (rolldown) can resolve them without cross-file augmentation.
*/
async function patchModuleAugmentations() {
console.log('\nPatching module augmentations in global.d.*.d.ts files...');
Expand All @@ -2096,31 +2096,180 @@ async function patchModuleAugmentations() {
return;
}

// Module augmentation mappings: bare specifier -> relative path from chunks/
const augmentationMappings: Record<string, string> = {
'@vitest/expect': '../@vitest/expect/index.js',
'@vitest/runner': '../@vitest/runner/index.js',
// Module augmentation mappings: bare specifier -> [relative path, target .d.ts file]
const augmentationMappings: Record<string, { relativePath: string; targetFile: string }> = {
'@vitest/expect': {
relativePath: '../@vitest/expect/index.js',
targetFile: join(distDir, '@vitest/expect/index.d.ts'),
},
'@vitest/runner': {
relativePath: '../@vitest/runner/index.js',
targetFile: join(distDir, '@vitest/runner/utils.d.ts'),
},
};

for (const file of globalDtsFiles) {
let content = await readFile(file, 'utf-8');
let modified = false;

for (const [bareSpecifier, relativePath] of Object.entries(augmentationMappings)) {
for (const [bareSpecifier, { relativePath, targetFile }] of Object.entries(
augmentationMappings,
)) {
const oldPattern = `declare module "${bareSpecifier}"`;
const newPattern = `declare module "${relativePath}"`;

if (content.includes(oldPattern)) {
content = content.replaceAll(oldPattern, newPattern);
modified = true;
console.log(` Patched: ${bareSpecifier} -> ${relativePath} in ${basename(file)}`);
// Extract the augmentation block content using brace matching
const startIdx = content.indexOf(oldPattern);
Comment thread
fengmk2 marked this conversation as resolved.
const braceStart = startIdx !== -1 ? content.indexOf('{', startIdx) : -1;
if (braceStart === -1) {
continue;
}

let depth = 0;
let braceEnd = -1;
for (let i = braceStart; i < content.length; i++) {
if (content[i] === '{') {
depth++;
} else if (content[i] === '}') {
depth--;
if (depth === 0) {
braceEnd = i;
break;
}
}
}
if (braceEnd === -1) {
continue;
}

const innerContent = content.slice(braceStart + 1, braceEnd).trim();

// Merge only NEW type declarations into the target .d.ts file.
// Interfaces that already exist (e.g., ExpectStatic, Assertion, MatcherState) must NOT
// be re-declared, as that would shadow extends clauses and break call signatures.
if (innerContent && existsSync(targetFile)) {
let targetContent = await readFile(targetFile, 'utf-8');

// Extract individual interface blocks from the augmentation content
const interfaceRegex = /(?:export\s+)?interface\s+(\w+)(?:<[^>]*>)?\s*\{/g;
let match;
const newDeclarations: string[] = [];

while ((match = interfaceRegex.exec(innerContent)) !== null) {
const name = match[1];
// Only merge if this interface does NOT already exist in the target file.
// Check both direct declarations (interface Name) and re-exports (export type { Name }).
const hasDirectDecl = new RegExp(`\\binterface\\s+${name}\\b`).test(targetContent);
const exportTypeMatch = targetContent.match(/export\s+type\s*\{([^}]*)\}/);
const isReExported =
exportTypeMatch != null && new RegExp(`\\b${name}\\b`).test(exportTypeMatch[1]);
if (hasDirectDecl || isReExported) {
console.log(
` Skipped existing interface "${name}" (already in ${basename(targetFile)})`,
);
continue;
}

// Extract this interface block using brace matching
const ifaceStart = match.index;
const ifaceBraceStart = innerContent.indexOf('{', ifaceStart);
let ifaceDepth = 0;
let ifaceBraceEnd = -1;
for (let i = ifaceBraceStart; i < innerContent.length; i++) {
if (innerContent[i] === '{') {
ifaceDepth++;
} else if (innerContent[i] === '}') {
ifaceDepth--;
if (ifaceDepth === 0) {
ifaceBraceEnd = i;
break;
}
}
}
if (ifaceBraceEnd === -1) {
continue;
}

let block = innerContent.slice(ifaceStart, ifaceBraceEnd + 1).trim();
if (!block.startsWith('export')) {
block = `export ${block}`;
}
newDeclarations.push(block);
console.log(` Merged new interface "${name}" into ${basename(targetFile)}`);
}

if (newDeclarations.length > 0) {
targetContent += `\n// Merged from module augmentation: declare module "${bareSpecifier}"\n${newDeclarations.join('\n')}\n`;
await writeFile(targetFile, targetContent, 'utf-8');
}
}

// Rewrite declare module path to relative
const newPattern = `declare module "${relativePath}"`;
content = content.replaceAll(oldPattern, newPattern);
modified = true;
console.log(` Patched: ${bareSpecifier} -> ${relativePath} in ${basename(file)}`);
}

if (modified) {
await writeFile(file, content, 'utf-8');
}
}

// Re-export BrowserCommands from context.d.ts (imported but not exported)
const contextDtsPath = join(distDir, '@vitest/browser/context.d.ts');
if (existsSync(contextDtsPath)) {
let content = await readFile(contextDtsPath, 'utf-8');
if (
content.includes('BrowserCommands') &&
!content.match(/export\s+(type\s+)?\{[^}]*BrowserCommands/)
) {
content += '\nexport type { BrowserCommands };\n';
await writeFile(contextDtsPath, content, 'utf-8');
console.log(' Added BrowserCommands re-export to context.d.ts');
}
}

// Validate: ensure no duplicate top-level interface declarations were introduced by merging.
// Only count interfaces at the module scope (not nested inside declare global, namespace, etc.)
for (const [bareSpecifier, { targetFile }] of Object.entries(augmentationMappings)) {
if (!existsSync(targetFile)) {
continue;
}
const finalContent = await readFile(targetFile, 'utf-8');

// Extract top-level interface names by tracking brace depth
const topLevelInterfaces: string[] = [];
let depth = 0;
for (let i = 0; i < finalContent.length; i++) {
if (finalContent[i] === '{') {
depth++;
} else if (finalContent[i] === '}') {
depth--;
} else if (depth === 0) {
const remaining = finalContent.slice(i);
const m = remaining.match(/^interface\s+(\w+)/);
if (m) {
topLevelInterfaces.push(m[1]);
i += m[0].length - 1;
}
}
}

const counts = new Map<string, number>();
for (const name of topLevelInterfaces) {
counts.set(name, (counts.get(name) || 0) + 1);
}

for (const [name, count] of counts) {
if (count > 1) {
throw new Error(
`Interface "${name}" is declared ${count} times at top level in ${basename(targetFile)}. ` +
`Module augmentation merge for "${bareSpecifier}" likely created a duplicate ` +
`declaration that will shadow extends clauses and break type signatures.`,
);
}
}
}
}

/**
Expand Down
Loading