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
181 changes: 175 additions & 6 deletions packages/cli/src/migration/__tests__/vitest-v5-lockfile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,33 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { afterEach, describe, expect, it } from 'vitest';
import { parse as parseJsonc } from 'jsonc-parser';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { parseDocument } from 'yaml';

import { PackageManager } from '../../types/index.ts';
import { applyVitestV5Migration, planVitestV5Migration } from '../migrator.ts';

vi.mock('yaml', async (importOriginal) => {
const yaml = await importOriginal<typeof import('yaml')>();
return { ...yaml, parseDocument: vi.fn(yaml.parseDocument) };
});

vi.mock('jsonc-parser', async (importOriginal) => {
const jsonc = await importOriginal<typeof import('jsonc-parser')>();
return { ...jsonc, parse: vi.fn(jsonc.parse) };
});

const directories: string[] = [];
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
for (const directory of directories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});

const formats = ['npm', 'yarn', 'yarn-classic', 'bun'] as const;
const formats = ['pnpm', 'npm', 'npm-shrinkwrap', 'yarn', 'yarn-classic', 'bun'] as const;
function project(
format: (typeof formats)[number],
spec = '^4 || ^5',
Expand All @@ -31,8 +45,16 @@ function project(
const manifest = { devDependencies: { vitest: lockedSpec } };
let filename: string;
let content: string;
if (format === 'npm') {
filename = 'package-lock.json';
if (format === 'pnpm') {
filename = 'pnpm-lock.yaml';
content = JSON.stringify({
lockfileVersion: '9.0',
importers: {
'.': { devDependencies: { vitest: { specifier: lockedSpec, version } } },
},
});
} else if (format === 'npm' || format === 'npm-shrinkwrap') {
filename = format === 'npm' ? 'package-lock.json' : 'npm-shrinkwrap.json';
content = JSON.stringify({
lockfileVersion: 3,
packages: { '': manifest, 'node_modules/vitest': { version } },
Expand All @@ -52,15 +74,21 @@ function project(
content = `# yarn lockfile v1\n\n${JSON.stringify(`vitest@${lockedSpec}`)}:\n version "${version}"\n resolved "https://registry.npmjs.org/vitest/-/vitest-${version}.tgz"\n`;
}
fs.writeFileSync(path.join(root, filename), content);
const manager = format === 'yarn-classic' ? PackageManager.yarn : PackageManager[format];
const manager =
format === 'yarn-classic'
? PackageManager.yarn
: format === 'npm-shrinkwrap'
? PackageManager.npm
: PackageManager[format];
return {
root,
manager,
file: path.join(root, filename),
plan: () => planVitestV5Migration({ rootDir: root, packageManager: manager }),
};
}

describe('Vitest source versions from non-pnpm lockfiles', () => {
describe('Vitest source versions from lockfiles', () => {
it.each(
formats.flatMap((format) => ['4.1.11', '5.0.0'].map((version) => [format, version] as const)),
)('uses the original %s lockfile runner %s without node_modules', (format, version) => {
Expand Down Expand Up @@ -118,6 +146,147 @@ describe('Vitest source versions from non-pnpm lockfiles', () => {
expect(plan.projects.map(({ sourceVersion }) => sourceVersion)).toEqual(['5.0.0', '4.1.11']);
});

it('reuses a pnpm lockfile within a plan without sharing versions between importers or plans', () => {
const fixture = project('pnpm');
const packages = [
{ name: 'unit', path: 'packages/unit' },
{ name: 'inherited', path: 'packages/inherited' },
];
for (const pkg of packages) {
fs.mkdirSync(path.join(fixture.root, pkg.path), { recursive: true });
fs.writeFileSync(
path.join(fixture.root, pkg.path, 'package.json'),
JSON.stringify(pkg.name === 'unit' ? { devDependencies: { vitest: '^4 || ^5' } } : {}),
);
}
const lock = JSON.parse(fs.readFileSync(fixture.file, 'utf8'));
lock.importers['packages/unit'] = {
devDependencies: { vitest: { specifier: '^4 || ^5', version: '5.0.0' } },
};
lock.importers['packages/inherited'] = {};
fs.writeFileSync(fixture.file, JSON.stringify(lock));
const workspace = { rootDir: fixture.root, packageManager: PackageManager.pnpm, packages };
const plan = planVitestV5Migration(workspace);
expect(plan.projects.map(({ sourceVersion }) => sourceVersion)).toEqual([
'4.1.11',
'5.0.0',
'4.1.11',
]);
expect(parseDocument).toHaveBeenCalledTimes(1);

lock.importers['.'].devDependencies.vitest.version = '5.0.0';
fs.writeFileSync(fixture.file, JSON.stringify(lock));
expect(() => applyVitestV5Migration(plan)).toThrow('Migration input changed');
expect(
planVitestV5Migration(workspace).projects.map(({ sourceVersion }) => sourceVersion),
).toEqual(['5.0.0', '5.0.0', '5.0.0']);
expect(parseDocument).toHaveBeenCalledTimes(2);

fs.writeFileSync(fixture.file, '{ invalid');
expect(
planVitestV5Migration(workspace).projects.map(({ sourceVersion }) => sourceVersion),
).toEqual([undefined, undefined, undefined]);
expect(parseDocument).toHaveBeenCalledTimes(3);
fs.writeFileSync(fixture.file, JSON.stringify(lock));
expect(
planVitestV5Migration(workspace).projects.map(({ sourceVersion }) => sourceVersion),
).toEqual(['5.0.0', '5.0.0', '5.0.0']);
expect(parseDocument).toHaveBeenCalledTimes(4);
});

it.each(formats)('reads and parses the %s lockfile once per plan', (format) => {
const fixture = project(format);
const member = 'packages/unit';
fs.mkdirSync(path.join(fixture.root, member), { recursive: true });
fs.writeFileSync(
path.join(fixture.root, member, 'package.json'),
JSON.stringify({ devDependencies: { vitest: '^4 || ^5' } }),
);
if (format === 'pnpm' || fixture.manager === PackageManager.npm || format === 'bun') {
const lock = JSON.parse(fs.readFileSync(fixture.file, 'utf8'));
const importers =
format === 'pnpm'
? lock.importers
: fixture.manager === PackageManager.npm
? lock.packages
: lock.workspaces;
importers[member] = importers[format === 'pnpm' ? '.' : ''];
fs.writeFileSync(fixture.file, JSON.stringify(lock));
}
const before = fs.readFileSync(fixture.file, 'utf8');
const reads = vi.spyOn(fs, 'readFileSync');
const json = vi.spyOn(JSON, 'parse');
const workspace = {
rootDir: fixture.root,
packageManager: fixture.manager,
packages: [{ name: 'unit', path: member }],
};
const plan = planVitestV5Migration(workspace);
expect(plan.projects.map(({ sourceVersion }) => sourceVersion)).toEqual(['4.1.11', '4.1.11']);
// One read captures the input snapshot and one parses the lockfile.
expect(reads.mock.calls.filter(([file]) => file === fixture.file)).toHaveLength(2);
if (fixture.manager === PackageManager.npm) {
expect(json.mock.calls.filter(([source]) => source === before)).toHaveLength(1);
} else if (format === 'bun') {
expect(vi.mocked(parseJsonc).mock.calls.filter(([source]) => source === before)).toHaveLength(
1,
);
} else {
expect(parseDocument).toHaveBeenCalledTimes(1);
}

const after = before.replaceAll('4.1.11', '5.0.0');
fs.writeFileSync(fixture.file, after);
expect(() => applyVitestV5Migration(plan)).toThrow('Migration input changed');
expect(
planVitestV5Migration(workspace).projects.map(({ sourceVersion }) => sourceVersion),
).toEqual(['5.0.0', '5.0.0']);

fs.writeFileSync(fixture.file, '{ invalid');
reads.mockClear();
expect(
planVitestV5Migration(workspace).projects.map(({ sourceVersion }) => sourceVersion),
).toEqual([undefined, undefined]);
expect(reads.mock.calls.filter(([file]) => file === fixture.file)).toHaveLength(2);
fs.writeFileSync(fixture.file, before);
expect(
planVitestV5Migration(workspace).projects.map(({ sourceVersion }) => sourceVersion),
).toEqual(['4.1.11', '4.1.11']);
});

it.each(['yarn', 'yarn-classic'] as const)(
'keeps %s descriptor resolution package-specific when sharing a lockfile',
(format) => {
const fixture = project(format, '^4.1.0');
fs.mkdirSync(path.join(fixture.root, 'unit'));
fs.writeFileSync(
path.join(fixture.root, 'unit/package.json'),
JSON.stringify({ devDependencies: { vitest: '^5.0.0' } }),
);
fs.appendFileSync(
fixture.file,
format === 'yarn'
? '\n"vitest@npm:^5.0.0":\n resolution: "vitest@npm:5.0.0"\n'
: '\n"vitest@^5.0.0":\n version "5.0.0"\n',
);
const plan = planVitestV5Migration({
rootDir: fixture.root,
packageManager: PackageManager.yarn,
packages: [{ name: 'unit', path: 'unit' }],
});
expect(plan.projects.map(({ sourceVersion }) => sourceVersion)).toEqual(['4.1.11', '5.0.0']);
expect(parseDocument).toHaveBeenCalledTimes(format === 'yarn' ? 1 : 2);
},
);

it('prefers npm shrinkwrap over package-lock.json', () => {
const fixture = project('npm-shrinkwrap');
const otherLock = JSON.parse(fs.readFileSync(fixture.file, 'utf8'));
otherLock.packages['node_modules/vitest'].version = '5.0.0';
fs.writeFileSync(path.join(fixture.root, 'package-lock.json'), JSON.stringify(otherLock));
expect(fixture.plan().projects[0].sourceVersion).toBe('4.1.11');
});

it('handles grouped Yarn classic descriptors', () => {
const fixture = project('yarn-classic');
fs.writeFileSync(
Expand Down
43 changes: 29 additions & 14 deletions packages/cli/src/migration/migrator/vitest-v5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ import {
migrateVitestV5Config,
} from '../vitest-v5/config.ts';
import { vitestV5Documentation } from '../vitest-v5/documentation.ts';
import { lockedVitestVersion } from '../vitest-v5/lockfile.ts';
import {
createLockfileReader,
lockedVitestVersion,
type LockfileReader,
} from '../vitest-v5/lockfile.ts';
import {
findVitestV5ConfigEntries,
resolveVitestV5TestModes,
Expand Down Expand Up @@ -180,25 +184,21 @@ function lockedSourceVersion(
directory: string,
root: string,
pkg: Record<string, unknown>,
readLockfile: LockfileReader,
manager?: PackageManager,
expectedRunnerPackage?: string,
): string | undefined {
if (manager !== undefined && manager !== PackageManager.pnpm) {
const spec = dependency(pkg, 'vitest');
return spec && !expectedRunnerPackage
? lockedVitestVersion(root, directory, manager, spec)
? lockedVitestVersion(root, directory, manager, spec, readLockfile)
: undefined;
}
const lockfile = path.join(root, 'pnpm-lock.yaml');
if (!fs.existsSync(lockfile)) {
return undefined;
}
try {
const document = parseDocument(fs.readFileSync(lockfile, 'utf8'));
if (document.errors.length) {
return undefined;
}
const lock = record(document.toJS());
const lock = readLockfile(path.join(root, 'pnpm-lock.yaml'), (source) => {
const document = parseDocument(source);
return document.errors.length ? undefined : record(document.toJS());
});
const importer = record(
record(lock?.importers)?.[path.relative(root, directory).replaceAll('\\', '/') || '.'],
);
Expand Down Expand Up @@ -327,6 +327,7 @@ function sourceVersion(
directory: string,
root: string,
pkg: Record<string, unknown>,
readLockfile: LockfileReader,
manager?: PackageManager,
): string | undefined {
const catalog = createCatalogDependencyResolver(root, manager ?? PackageManager.pnpm);
Expand Down Expand Up @@ -357,7 +358,14 @@ function sourceVersion(
// A stale install or catalog lock entry can still resolve upstream Vitest
// under this name. Its version is not evidence for the declared wrapper.
const expectedRunnerPackage = legacyAlias ? LEGACY_RUNNER : undefined;
const locked = lockedSourceVersion(directory, root, pkg, manager, expectedRunnerPackage);
const locked = lockedSourceVersion(
directory,
root,
pkg,
readLockfile,
manager,
expectedRunnerPackage,
);
if (
locked &&
(!spec ||
Expand Down Expand Up @@ -390,7 +398,13 @@ function sourceVersion(
}
// A workspace member commonly uses the root's test command and dependencies.
if (directory !== root) {
return sourceVersion(root, root, readJson(path.join(root, 'package.json')), manager);
return sourceVersion(
root,
root,
readJson(path.join(root, 'package.json')),
readLockfile,
manager,
);
}
return undefined;
}
Expand Down Expand Up @@ -685,6 +699,7 @@ export function planVitestV5Migration(
workspace: MigrationWorkspace,
originalProjects?: ReadonlyMap<string, ProjectPlan>,
): VitestV5MigrationPlan {
const readLockfile = createLockfileReader();
const findings: VitestV5Finding[] = [];
const directories = [
workspace.rootDir,
Expand Down Expand Up @@ -726,7 +741,7 @@ export function planVitestV5Migration(
const original = originalProjects?.get(directory);
const version = original
? original.sourceVersion
: sourceVersion(directory, workspace.rootDir, pkg, workspace.packageManager);
: sourceVersion(directory, workspace.rootDir, pkg, readLockfile, workspace.packageManager);
const active =
original?.active ??
(!!dependency(pkg, 'vitest') ||
Expand Down
Loading
Loading