Skip to content
Draft
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
104 changes: 104 additions & 0 deletions .github/scripts/__tests__/publish-npm-package.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/// <reference types="node" />

import { describe, expect, test, vi } from 'vitest';

import {
type PublishCommandRunner,
type PublishNpmPackageOptions,
isAlreadyPublishedError,
publishNpmPackage,
} from '../publish-npm-package.ts';
import type { FetchLike } from '../wait-for-npm-packages.ts';

const pkg = { name: '@scope/pkg', version: '1.2.3' };

function response(status: number, body: unknown = undefined) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
};
}

function options(fetchImpl: FetchLike, runCommand: PublishCommandRunner): PublishNpmPackageOptions {
return {
pkg,
command: 'npm',
args: ['publish'],
cwd: '/workspace/pkg',
registry: 'https://registry.npmjs.org',
fetchImpl,
runCommand,
log: vi.fn(),
warn: vi.fn(),
};
}

describe('publishNpmPackage', () => {
test('skips an exact version that is already visible', async () => {
const fetchImpl = vi.fn<FetchLike>().mockResolvedValue(
response(200, {
versions: { '1.2.3': {} },
}),
);
const runCommand = vi.fn<PublishCommandRunner>();

await expect(publishNpmPackage(options(fetchImpl, runCommand))).resolves.toBe(
'already-published',
);
expect(runCommand).not.toHaveBeenCalled();
});

test('publishes a version that is not visible', async () => {
const fetchImpl = vi.fn<FetchLike>().mockResolvedValue(response(404));
const runCommand = vi.fn<PublishCommandRunner>().mockResolvedValue({
exitCode: 0,
output: 'published',
});

await expect(publishNpmPackage(options(fetchImpl, runCommand))).resolves.toBe('published');
expect(runCommand).toHaveBeenCalledWith('npm', ['publish'], '/workspace/pkg');
});

test('recovers when npm accepted a version that scanning still hides', async () => {
const fetchImpl = vi.fn<FetchLike>().mockResolvedValue(response(404));
const runCommand = vi.fn<PublishCommandRunner>().mockResolvedValue({
exitCode: 1,
output:
'npm error 403 Forbidden - You cannot publish over the previously published versions: 1.2.3.',
});

await expect(publishNpmPackage(options(fetchImpl, runCommand))).resolves.toBe(
'already-published',
);
});

test('does not swallow unrelated publish failures', async () => {
const fetchImpl = vi.fn<FetchLike>().mockResolvedValue(response(404));
const runCommand = vi.fn<PublishCommandRunner>().mockResolvedValue({
exitCode: 1,
output: 'npm error 403 Authentication failed',
});

await expect(publishNpmPackage(options(fetchImpl, runCommand))).rejects.toThrow(
'Failed to publish @scope/pkg@1.2.3: exit code 1',
);
});

test('publishes after a transient preflight read failure', async () => {
const fetchImpl = vi.fn<FetchLike>().mockRejectedValue(new Error('registry unavailable'));
const runCommand = vi.fn<PublishCommandRunner>().mockResolvedValue({
exitCode: 0,
output: 'published',
});
const publishOptions = options(fetchImpl, runCommand);

await expect(publishNpmPackage(publishOptions)).resolves.toBe('published');
expect(publishOptions.warn).toHaveBeenCalledOnce();
});
});

test('recognizes npm immutable-version errors only', () => {
expect(isAlreadyPublishedError('npm ERR! code EPUBLISHCONFLICT')).toBe(true);
expect(isAlreadyPublishedError('npm error 403 Authentication failed')).toBe(false);
});
157 changes: 157 additions & 0 deletions .github/scripts/__tests__/wait-for-npm-packages.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/// <reference types="node" />

import { describe, expect, test, vi } from 'vitest';

import {
type FetchLike,
isNpmPackageAvailable,
parseNpmPackageSpec,
waitForNpmPackages,
} from '../wait-for-npm-packages.ts';

function response(status: number, body: unknown = undefined) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
};
}

describe('isNpmPackageAvailable', () => {
test('checks the abbreviated packument and its tarball', async () => {
const fetchImpl = vi
.fn<FetchLike>()
.mockResolvedValueOnce(
response(200, {
versions: {
'1.2.3': {
dist: {
tarball: 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz',
},
},
},
}),
)
.mockResolvedValueOnce(response(200));

await expect(
isNpmPackageAvailable(
{ name: '@scope/pkg', version: '1.2.3' },
{ registry: 'https://registry.npmjs.org/', fetchImpl },
),
).resolves.toBe(true);

expect(fetchImpl).toHaveBeenNthCalledWith(1, 'https://registry.npmjs.org/@scope%2fpkg', {
headers: { accept: 'application/vnd.npm.install-v1+json' },
});
expect(fetchImpl).toHaveBeenNthCalledWith(2, 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz', {
method: 'HEAD',
});
});

test('is unavailable while the version or tarball is missing', async () => {
const missingVersion = vi.fn<FetchLike>().mockResolvedValue(
response(200, {
versions: { '1.2.2': {} },
}),
);
await expect(
isNpmPackageAvailable(
{ name: 'pkg', version: '1.2.3' },
{ registry: 'https://registry.npmjs.org', fetchImpl: missingVersion },
),
).resolves.toBe(false);

const missingTarball = vi
.fn<FetchLike>()
.mockResolvedValueOnce(
response(200, {
versions: {
'1.2.3': {
dist: {
tarball: 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz',
},
},
},
}),
)
.mockResolvedValueOnce(response(404));
await expect(
isNpmPackageAvailable(
{ name: 'pkg', version: '1.2.3' },
{ registry: 'https://registry.npmjs.org', fetchImpl: missingTarball },
),
).resolves.toBe(false);
});
});

describe('waitForNpmPackages', () => {
test('polls until available and always settles after the successful read', async () => {
let currentTime = 0;
const sleep = vi.fn(async (milliseconds: number) => {
currentTime += milliseconds;
});
const fetchImpl = vi
.fn<FetchLike>()
.mockResolvedValueOnce(response(404))
.mockResolvedValueOnce(
response(200, {
versions: {
'1.2.3': {
dist: {
tarball: 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz',
},
},
},
}),
)
.mockResolvedValueOnce(response(200));

await waitForNpmPackages([{ name: 'pkg', version: '1.2.3' }], {
registry: 'https://registry.npmjs.org',
fetchImpl,
minSeconds: 60,
timeoutSeconds: 600,
pollSeconds: 5,
sleep,
now: () => currentTime,
log: vi.fn(),
});

expect(sleep.mock.calls).toEqual([[5_000], [60_000]]);
});

test('retries transient read failures until the timeout', async () => {
let currentTime = 0;
const fetchImpl = vi.fn<FetchLike>().mockRejectedValue(new Error('temporary failure'));

await expect(
waitForNpmPackages([{ name: 'pkg', version: '1.2.3' }], {
registry: 'https://registry.npmjs.org',
fetchImpl,
minSeconds: 0,
timeoutSeconds: 5,
pollSeconds: 5,
sleep: async (milliseconds) => {
currentTime += milliseconds;
},
now: () => currentTime,
log: vi.fn(),
}),
).rejects.toThrow('Timed out after 5s waiting for npm propagation: pkg@1.2.3');

expect(fetchImpl).toHaveBeenCalledTimes(2);
});
});

test('parseNpmPackageSpec supports scoped and unscoped package names', () => {
expect(parseNpmPackageSpec('@scope/pkg@1.2.3')).toEqual({
name: '@scope/pkg',
version: '1.2.3',
});
expect(parseNpmPackageSpec('pkg@1.2.3')).toEqual({
name: 'pkg',
version: '1.2.3',
});
expect(() => parseNpmPackageSpec('@scope/pkg')).toThrow('name@version');
});
Loading
Loading