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
21 changes: 21 additions & 0 deletions .github/scripts/__tests__/npm-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { FetchLike } from '../wait-for-npm-packages.ts';

export function response(status: number, body?: unknown): Awaited<ReturnType<FetchLike>> {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
};
}

export function stalledFetch(_url: string, init?: Parameters<FetchLike>[1]): ReturnType<FetchLike> {
return new Promise((_resolve, reject) => {
const signal = init?.signal;
if (!signal) {
reject(new Error('missing abort signal'));
return;
}
signal.throwIfAborted();
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
}
121 changes: 121 additions & 0 deletions .github/scripts/__tests__/publish-npm-package.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/// <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';
import { response, stalledFetch } from './npm-registry.ts';

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

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('publishes after a stalled preflight read times out', async ({ onTestFinished }) => {
vi.useFakeTimers();
onTestFinished(() => {
vi.useRealTimers();
});
const fetchImpl = vi.fn(stalledFetch);
const runCommand = vi.fn<PublishCommandRunner>().mockResolvedValue({
exitCode: 0,
output: 'published',
});
const publishOptions = options(fetchImpl, runCommand);
const result = publishNpmPackage(publishOptions);

await vi.advanceTimersByTimeAsync(9_999);
expect(runCommand).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);

await expect(result).resolves.toBe('published');
expect(publishOptions.warn).toHaveBeenCalledOnce();
expect(runCommand).toHaveBeenCalledWith('npm', ['publish'], '/workspace/pkg');
expect(fetchImpl.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
expect(vi.getTimerCount()).toBe(0);
});
});

test('recognizes npm immutable-version errors only', () => {
expect(isAlreadyPublishedError('npm ERR! code EPUBLISHCONFLICT')).toBe(true);
expect(isAlreadyPublishedError('npm error 403 Authentication failed')).toBe(false);
});
152 changes: 152 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,152 @@
/// <reference types="node" />

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

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

const pkg = { name: 'pkg', version: '1.2.3' };
const tarball = 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz';
const packument = { versions: { '1.2.3': { dist: { tarball } } } };
const pendingPackages = [
{ name: 'first', version: '1.2.3' },
{ name: 'second', version: '1.2.3' },
];

function options(
fetchImpl: FetchLike,
overrides: Partial<WaitForNpmPackagesOptions> = {},
): WaitForNpmPackagesOptions {
return {
registry: 'https://registry.npmjs.org',
fetchImpl,
minSeconds: 0,
timeoutSeconds: 5,
pollSeconds: 1,
sleep: vi.fn(
(milliseconds) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)),
),
now: Date.now,
log: vi.fn(),
...overrides,
};
}

describe('isNpmPackageAvailable', () => {
test('checks the abbreviated packument and its tarball', async () => {
const fetchImpl = vi
.fn<FetchLike>()
.mockResolvedValueOnce(response(200, packument))
.mockResolvedValueOnce(response(200));

await expect(
isNpmPackageAvailable(
{ ...pkg, name: '@scope/pkg' },
{ 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' },
signal: undefined,
});
expect(fetchImpl).toHaveBeenNthCalledWith(2, tarball, {
method: 'HEAD',
signal: undefined,
});
});

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(pkg, options(missingVersion))).resolves.toBe(false);

const missingTarball = vi
.fn<FetchLike>()
.mockResolvedValueOnce(response(200, packument))
.mockResolvedValueOnce(response(404));
await expect(isNpmPackageAvailable(pkg, options(missingTarball))).resolves.toBe(false);
});
});

describe('waitForNpmPackages', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

test('polls until available and always settles after the successful read', async () => {
const fetchImpl = vi
.fn<FetchLike>()
.mockResolvedValueOnce(response(404))
.mockResolvedValueOnce(response(200, packument))
.mockResolvedValueOnce(response(200));
const waitOptions = options(fetchImpl, { minSeconds: 60, timeoutSeconds: 600, pollSeconds: 5 });
const result = waitForNpmPackages([pkg], waitOptions);

await vi.advanceTimersByTimeAsync(65_000);
await result;

expect(waitOptions.sleep).toHaveBeenCalledTimes(2);
expect(waitOptions.sleep).toHaveBeenNthCalledWith(1, 5_000);
expect(waitOptions.sleep).toHaveBeenNthCalledWith(2, 60_000);
expect(vi.getTimerCount()).toBe(0);
});

test('retries transient read failures until the timeout', async () => {
const fetchImpl = vi.fn<FetchLike>().mockRejectedValue(new Error('temporary failure'));
const waitOptions = options(fetchImpl, { pollSeconds: 2 });
const result = expect(waitForNpmPackages([pkg], waitOptions)).rejects.toThrow(
'Timed out after 5s waiting for npm propagation: pkg@1.2.3',
);

await vi.advanceTimersByTimeAsync(5_000);
await result;

expect(fetchImpl).toHaveBeenCalledTimes(3);
expect(waitOptions.sleep).toHaveBeenLastCalledWith(1_000);
expect(vi.getTimerCount()).toBe(0);
});

test('checks the deadline before each package', async () => {
const fetchImpl = vi.fn<FetchLike>(async () => {
vi.setSystemTime(Date.now() + 5_000);
return response(404);
});

await expect(waitForNpmPackages(pendingPackages, options(fetchImpl))).rejects.toThrow(
'Timed out after 5s waiting for npm propagation: first@1.2.3, second@1.2.3',
);

expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});

test('aborts a stalled request at the deadline and skips later packages', async () => {
const fetchImpl = vi.fn(stalledFetch);
const result = expect(waitForNpmPackages(pendingPackages, options(fetchImpl))).rejects.toThrow(
'Timed out after 5s waiting for npm propagation: first@1.2.3, second@1.2.3',
);

await vi.advanceTimersByTimeAsync(5_000);
await result;

expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(fetchImpl.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
expect(vi.getTimerCount()).toBe(0);
});
});

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(pkg);
expect(() => parseNpmPackageSpec('@scope/pkg')).toThrow('name@version');
});
Loading
Loading