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
98 changes: 62 additions & 36 deletions code/core/src/common/js-package-manager/PNPMProxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,13 @@ describe('PNPM Proxy', () => {
});

describe('precheckStorybookPackageInstall', () => {
const packageTimeMap = {
created: '2025-01-01T00:00:00.000Z',
modified: '2026-05-11T12:00:00.000Z',
'10.4.0-alpha.17': '2026-05-11T11:59:00.000Z',
'10.3.2': '2026-05-01T00:00:00.000Z',
};

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-11T12:00:00.000Z'));
Expand All @@ -495,15 +502,8 @@ describe('PNPM Proxy', () => {
mockedExecuteCommand
.mockResolvedValueOnce({ stdout: '1440\n' } as any)
.mockResolvedValueOnce({ stdout: JSON.stringify(['react', 'webpack']) } as any)
.mockResolvedValueOnce({ stdout: '"2026-05-11T11:59:00.000Z"' } as any)
.mockResolvedValueOnce({
stdout: JSON.stringify({
created: '2025-01-01T00:00:00.000Z',
modified: '2026-05-11T12:00:00.000Z',
'10.4.0-alpha.17': '2026-05-11T11:59:00.000Z',
'10.3.2': '2026-05-01T00:00:00.000Z',
}),
} as any)
.mockResolvedValueOnce({ stdout: JSON.stringify(packageTimeMap) } as any)
.mockResolvedValueOnce({ stdout: JSON.stringify(packageTimeMap) } as any)
.mockResolvedValueOnce({ stdout: JSON.stringify(['react', 'webpack']) } as any)
.mockResolvedValueOnce({ stdout: '' } as any);
vi.mocked(prompt.executeTaskWithSpinner).mockImplementationOnce(async (factory: any) => {
Expand Down Expand Up @@ -537,15 +537,8 @@ describe('PNPM Proxy', () => {
.mockResolvedValueOnce({
stdout: JSON.stringify(['react', '@storybook/preset-react-webpack']),
} as any)
.mockResolvedValueOnce({ stdout: '"2026-05-11T11:59:00.000Z"' } as any)
.mockResolvedValueOnce({
stdout: JSON.stringify({
created: '2025-01-01T00:00:00.000Z',
modified: '2026-05-11T12:00:00.000Z',
'10.4.0-alpha.17': '2026-05-11T11:59:00.000Z',
'10.3.2': '2026-05-01T00:00:00.000Z',
}),
} as any)
.mockResolvedValueOnce({ stdout: JSON.stringify(packageTimeMap) } as any)
.mockResolvedValueOnce({ stdout: JSON.stringify(packageTimeMap) } as any)
.mockResolvedValueOnce({ stdout: JSON.stringify(['react', 'webpack']) } as any)
.mockResolvedValueOnce({ stdout: '' } as any);
vi.mocked(prompt.select).mockResolvedValue('exclude' as never);
Expand Down Expand Up @@ -602,15 +595,8 @@ describe('PNPM Proxy', () => {
mockedExecuteCommand
.mockResolvedValueOnce({ stdout: '1440\n' } as any)
.mockResolvedValueOnce({ stdout: '[]' } as any)
.mockResolvedValueOnce({ stdout: '"2026-05-11T11:59:00.000Z"' } as any)
.mockResolvedValueOnce({
stdout: JSON.stringify({
created: '2025-01-01T00:00:00.000Z',
modified: '2026-05-11T12:00:00.000Z',
'10.4.0-alpha.17': '2026-05-11T11:59:00.000Z',
'10.3.2': '2026-05-01T00:00:00.000Z',
}),
} as any);
.mockResolvedValueOnce({ stdout: JSON.stringify([packageTimeMap]) } as any)
.mockResolvedValueOnce({ stdout: JSON.stringify([packageTimeMap]) } as any);
vi.mocked(prompt.select).mockResolvedValue('rerun' as never);

const rerunPromise = pnpmProxy.precheckStorybookPackageInstall({
Expand All @@ -632,15 +618,8 @@ describe('PNPM Proxy', () => {
mockedExecuteCommand
.mockResolvedValueOnce({ stdout: '1440\n' } as any)
.mockResolvedValueOnce({ stdout: '[]' } as any)
.mockResolvedValueOnce({ stdout: '"2026-05-11T11:59:00.000Z"' } as any)
.mockResolvedValueOnce({
stdout: JSON.stringify({
created: '2025-01-01T00:00:00.000Z',
modified: '2026-05-11T12:00:00.000Z',
'10.4.0-alpha.17': '2026-05-11T11:59:00.000Z',
'10.3.2': '2026-05-01T00:00:00.000Z',
}),
} as any);
.mockResolvedValueOnce({ stdout: JSON.stringify(packageTimeMap) } as any)
.mockResolvedValueOnce({ stdout: JSON.stringify(packageTimeMap) } as any);
vi.mocked(prompt.select).mockImplementationOnce(async (_options: any, promptOptions: any) => {
promptOptions.onCancel();
return 'exclude';
Expand All @@ -657,6 +636,53 @@ describe('PNPM Proxy', () => {
);
});

describe('release-time checks', () => {
beforeEach(() => {
mockedExecuteCommand
.mockResolvedValueOnce({ stdout: '1440\n' } as Awaited<ReturnType<typeof executeCommand>>)
.mockResolvedValueOnce({ stdout: '[]' } as Awaited<ReturnType<typeof executeCommand>>)
.mockImplementation(
({ args = [] }) =>
Promise.resolve({
stdout: args.includes('time') ? JSON.stringify(packageTimeMap) : '',
}) as ReturnType<typeof executeCommand>
);
vi.mocked(prompt.select).mockResolvedValue('rerun');
});

it('should prompt before installing a release that is too recent', async () => {
await expect(
pnpmProxy.precheckStorybookPackageInstall({
storybookVersion: '10.4.0-alpha.17',
nonInteractive: false,
installContext: 'create',
})
).rejects.toThrow(MinimumReleaseAgeHandledError);

expect(prompt.select).toHaveBeenCalled();
});

it.each([
{ version: '10.3.2', condition: 'already satisfies the minimum release age' },
{ version: '10.4.0-alpha.18', condition: 'has no release time' },
])(
'should continue without prompting when the requested version $condition',
async ({ version }) => {
await expect(
pnpmProxy.precheckStorybookPackageInstall({
storybookVersion: version,
nonInteractive: false,
installContext: 'create',
})
).resolves.toBeUndefined();

expect(prompt.select).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
expect(prompt.executeTaskWithSpinner).not.toHaveBeenCalled();
}
);
});

it('should skip the precheck when Storybook packages are already excluded', async () => {
const updateSpy = vi.spyOn(pnpmProxy as any, 'updateMinimumReleaseAgeExclude');
mockedExecuteCommand
Expand Down
5 changes: 3 additions & 2 deletions code/core/src/common/js-package-manager/PNPMProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,7 @@ export class PNPMProxy extends JsPackageManager {
private async getPackageReleaseTime(packageName: string, version: string): Promise<Date | null> {
const result = await this.runInternalCommand(
'view',
['--json', packageName, `time[${version}]`],
['--json', packageName, 'time'],
undefined,
'pipe'
);
Expand All @@ -639,7 +639,8 @@ export class PNPMProxy extends JsPackageManager {
return null;
}

return parseReleaseTime(JSON.parse(normalizedValue));
const timeMap = parsePackageTimeMap(JSON.parse(normalizedValue));
return parseReleaseTime(timeMap?.[version]);
}

private async getLatestStableVersionAdheringToMinimumReleaseAge(
Expand Down
25 changes: 25 additions & 0 deletions code/core/src/common/js-package-manager/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,31 @@ describe('js package manager util', () => {
expect(parsePackageTimeMap('bad')).toBeNull();
});

it('parses package time maps wrapped in an array', () => {
expect(
parsePackageTimeMap([
{
created: '2025-01-01T00:00:00.000Z',
'10.4.0-alpha.17': '2026-05-11T11:59:00.000Z',
invalid: null,
},
])
).toEqual({
created: '2025-01-01T00:00:00.000Z',
'10.4.0-alpha.17': '2026-05-11T11:59:00.000Z',
});
});

it.each([
{ input: [], description: 'an empty array' },
{ input: [{}, {}], description: 'multiple time maps' },
{ input: [[{}]], description: 'a nested array' },
{ input: [null], description: 'an array containing null' },
{ input: ['2026-05-11T11:59:00.000Z'], description: 'an array containing a timestamp' },
])('rejects $description as a package time map', ({ input }) => {
expect(parsePackageTimeMap(input)).toBeNull();
});

it('computes package age in minutes', () => {
expect(
getAgeInMinutes(new Date('2026-05-11T11:00:00.000Z'), new Date('2026-05-11T12:30:00.000Z'))
Expand Down
6 changes: 6 additions & 0 deletions code/core/src/common/js-package-manager/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ export const parseReleaseTime = (value: unknown): Date | null => {
};

export const parsePackageTimeMap = (value: unknown): Record<string, string> | null => {
// pnpm < 11 forwards `view` to npm. With npm 12, the time map is wrapped in an array.
// https://github.com/npm/cli/releases/tag/v12.0.0
if (Array.isArray(value) && value.length === 1) {
value = value[0];
}

if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
Expand Down
Loading