Skip to content

Commit 8f1ee79

Browse files
authored
Merge branch 'main' into liang/codex/refactor-installer
2 parents 0d2cb88 + b87593c commit 8f1ee79

26 files changed

Lines changed: 1235 additions & 38 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { FetchLike } from '../wait-for-npm-packages.ts';
2+
3+
export function response(status: number, body?: unknown): Awaited<ReturnType<FetchLike>> {
4+
return {
5+
ok: status >= 200 && status < 300,
6+
status,
7+
json: async () => body,
8+
};
9+
}
10+
11+
export function stalledFetch(_url: string, init?: Parameters<FetchLike>[1]): ReturnType<FetchLike> {
12+
return new Promise((_resolve, reject) => {
13+
const signal = init?.signal;
14+
if (!signal) {
15+
reject(new Error('missing abort signal'));
16+
return;
17+
}
18+
signal.throwIfAborted();
19+
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
20+
});
21+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/// <reference types="node" />
2+
3+
import { describe, expect, test, vi } from 'vitest';
4+
5+
import {
6+
type PublishCommandRunner,
7+
type PublishNpmPackageOptions,
8+
isAlreadyPublishedError,
9+
publishNpmPackage,
10+
} from '../publish-npm-package.ts';
11+
import type { FetchLike } from '../wait-for-npm-packages.ts';
12+
import { response, stalledFetch } from './npm-registry.ts';
13+
14+
const pkg = { name: '@scope/pkg', version: '1.2.3' };
15+
16+
function options(fetchImpl: FetchLike, runCommand: PublishCommandRunner): PublishNpmPackageOptions {
17+
return {
18+
pkg,
19+
command: 'npm',
20+
args: ['publish'],
21+
cwd: '/workspace/pkg',
22+
registry: 'https://registry.npmjs.org',
23+
fetchImpl,
24+
runCommand,
25+
log: vi.fn(),
26+
warn: vi.fn(),
27+
};
28+
}
29+
30+
describe('publishNpmPackage', () => {
31+
test('skips an exact version that is already visible', async () => {
32+
const fetchImpl = vi.fn<FetchLike>().mockResolvedValue(
33+
response(200, {
34+
versions: { '1.2.3': {} },
35+
}),
36+
);
37+
const runCommand = vi.fn<PublishCommandRunner>();
38+
39+
await expect(publishNpmPackage(options(fetchImpl, runCommand))).resolves.toBe(
40+
'already-published',
41+
);
42+
expect(runCommand).not.toHaveBeenCalled();
43+
});
44+
45+
test('publishes a version that is not visible', async () => {
46+
const fetchImpl = vi.fn<FetchLike>().mockResolvedValue(response(404));
47+
const runCommand = vi.fn<PublishCommandRunner>().mockResolvedValue({
48+
exitCode: 0,
49+
output: 'published',
50+
});
51+
52+
await expect(publishNpmPackage(options(fetchImpl, runCommand))).resolves.toBe('published');
53+
expect(runCommand).toHaveBeenCalledWith('npm', ['publish'], '/workspace/pkg');
54+
});
55+
56+
test('recovers when npm accepted a version that scanning still hides', async () => {
57+
const fetchImpl = vi.fn<FetchLike>().mockResolvedValue(response(404));
58+
const runCommand = vi.fn<PublishCommandRunner>().mockResolvedValue({
59+
exitCode: 1,
60+
output:
61+
'npm error 403 Forbidden - You cannot publish over the previously published versions: 1.2.3.',
62+
});
63+
64+
await expect(publishNpmPackage(options(fetchImpl, runCommand))).resolves.toBe(
65+
'already-published',
66+
);
67+
});
68+
69+
test('does not swallow unrelated publish failures', async () => {
70+
const fetchImpl = vi.fn<FetchLike>().mockResolvedValue(response(404));
71+
const runCommand = vi.fn<PublishCommandRunner>().mockResolvedValue({
72+
exitCode: 1,
73+
output: 'npm error 403 Authentication failed',
74+
});
75+
76+
await expect(publishNpmPackage(options(fetchImpl, runCommand))).rejects.toThrow(
77+
'Failed to publish @scope/pkg@1.2.3: exit code 1',
78+
);
79+
});
80+
81+
test('publishes after a transient preflight read failure', async () => {
82+
const fetchImpl = vi.fn<FetchLike>().mockRejectedValue(new Error('registry unavailable'));
83+
const runCommand = vi.fn<PublishCommandRunner>().mockResolvedValue({
84+
exitCode: 0,
85+
output: 'published',
86+
});
87+
const publishOptions = options(fetchImpl, runCommand);
88+
89+
await expect(publishNpmPackage(publishOptions)).resolves.toBe('published');
90+
expect(publishOptions.warn).toHaveBeenCalledOnce();
91+
});
92+
93+
test('publishes after a stalled preflight read times out', async ({ onTestFinished }) => {
94+
vi.useFakeTimers();
95+
onTestFinished(() => {
96+
vi.useRealTimers();
97+
});
98+
const fetchImpl = vi.fn(stalledFetch);
99+
const runCommand = vi.fn<PublishCommandRunner>().mockResolvedValue({
100+
exitCode: 0,
101+
output: 'published',
102+
});
103+
const publishOptions = options(fetchImpl, runCommand);
104+
const result = publishNpmPackage(publishOptions);
105+
106+
await vi.advanceTimersByTimeAsync(9_999);
107+
expect(runCommand).not.toHaveBeenCalled();
108+
await vi.advanceTimersByTimeAsync(1);
109+
110+
await expect(result).resolves.toBe('published');
111+
expect(publishOptions.warn).toHaveBeenCalledOnce();
112+
expect(runCommand).toHaveBeenCalledWith('npm', ['publish'], '/workspace/pkg');
113+
expect(fetchImpl.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
114+
expect(vi.getTimerCount()).toBe(0);
115+
});
116+
});
117+
118+
test('recognizes npm immutable-version errors only', () => {
119+
expect(isAlreadyPublishedError('npm ERR! code EPUBLISHCONFLICT')).toBe(true);
120+
expect(isAlreadyPublishedError('npm error 403 Authentication failed')).toBe(false);
121+
});
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/// <reference types="node" />
2+
3+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
4+
5+
import {
6+
type FetchLike,
7+
type WaitForNpmPackagesOptions,
8+
isNpmPackageAvailable,
9+
parseNpmPackageSpec,
10+
waitForNpmPackages,
11+
} from '../wait-for-npm-packages.ts';
12+
import { response, stalledFetch } from './npm-registry.ts';
13+
14+
const pkg = { name: 'pkg', version: '1.2.3' };
15+
const tarball = 'https://registry.npmjs.org/pkg/-/pkg-1.2.3.tgz';
16+
const packument = { versions: { '1.2.3': { dist: { tarball } } } };
17+
const pendingPackages = [
18+
{ name: 'first', version: '1.2.3' },
19+
{ name: 'second', version: '1.2.3' },
20+
];
21+
22+
function options(
23+
fetchImpl: FetchLike,
24+
overrides: Partial<WaitForNpmPackagesOptions> = {},
25+
): WaitForNpmPackagesOptions {
26+
return {
27+
registry: 'https://registry.npmjs.org',
28+
fetchImpl,
29+
minSeconds: 0,
30+
timeoutSeconds: 5,
31+
pollSeconds: 1,
32+
sleep: vi.fn(
33+
(milliseconds) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)),
34+
),
35+
now: Date.now,
36+
log: vi.fn(),
37+
...overrides,
38+
};
39+
}
40+
41+
describe('isNpmPackageAvailable', () => {
42+
test('checks the abbreviated packument and its tarball', async () => {
43+
const fetchImpl = vi
44+
.fn<FetchLike>()
45+
.mockResolvedValueOnce(response(200, packument))
46+
.mockResolvedValueOnce(response(200));
47+
48+
await expect(
49+
isNpmPackageAvailable(
50+
{ ...pkg, name: '@scope/pkg' },
51+
{ registry: 'https://registry.npmjs.org/', fetchImpl },
52+
),
53+
).resolves.toBe(true);
54+
55+
expect(fetchImpl).toHaveBeenNthCalledWith(1, 'https://registry.npmjs.org/@scope%2fpkg', {
56+
headers: { accept: 'application/vnd.npm.install-v1+json' },
57+
signal: undefined,
58+
});
59+
expect(fetchImpl).toHaveBeenNthCalledWith(2, tarball, {
60+
method: 'HEAD',
61+
signal: undefined,
62+
});
63+
});
64+
65+
test('is unavailable while the version or tarball is missing', async () => {
66+
const missingVersion = vi
67+
.fn<FetchLike>()
68+
.mockResolvedValue(response(200, { versions: { '1.2.2': {} } }));
69+
await expect(isNpmPackageAvailable(pkg, options(missingVersion))).resolves.toBe(false);
70+
71+
const missingTarball = vi
72+
.fn<FetchLike>()
73+
.mockResolvedValueOnce(response(200, packument))
74+
.mockResolvedValueOnce(response(404));
75+
await expect(isNpmPackageAvailable(pkg, options(missingTarball))).resolves.toBe(false);
76+
});
77+
});
78+
79+
describe('waitForNpmPackages', () => {
80+
beforeEach(() => vi.useFakeTimers());
81+
afterEach(() => vi.useRealTimers());
82+
83+
test('polls until available and always settles after the successful read', async () => {
84+
const fetchImpl = vi
85+
.fn<FetchLike>()
86+
.mockResolvedValueOnce(response(404))
87+
.mockResolvedValueOnce(response(200, packument))
88+
.mockResolvedValueOnce(response(200));
89+
const waitOptions = options(fetchImpl, { minSeconds: 60, timeoutSeconds: 600, pollSeconds: 5 });
90+
const result = waitForNpmPackages([pkg], waitOptions);
91+
92+
await vi.advanceTimersByTimeAsync(65_000);
93+
await result;
94+
95+
expect(waitOptions.sleep).toHaveBeenCalledTimes(2);
96+
expect(waitOptions.sleep).toHaveBeenNthCalledWith(1, 5_000);
97+
expect(waitOptions.sleep).toHaveBeenNthCalledWith(2, 60_000);
98+
expect(vi.getTimerCount()).toBe(0);
99+
});
100+
101+
test('retries transient read failures until the timeout', async () => {
102+
const fetchImpl = vi.fn<FetchLike>().mockRejectedValue(new Error('temporary failure'));
103+
const waitOptions = options(fetchImpl, { pollSeconds: 2 });
104+
const result = expect(waitForNpmPackages([pkg], waitOptions)).rejects.toThrow(
105+
'Timed out after 5s waiting for npm propagation: pkg@1.2.3',
106+
);
107+
108+
await vi.advanceTimersByTimeAsync(5_000);
109+
await result;
110+
111+
expect(fetchImpl).toHaveBeenCalledTimes(3);
112+
expect(waitOptions.sleep).toHaveBeenLastCalledWith(1_000);
113+
expect(vi.getTimerCount()).toBe(0);
114+
});
115+
116+
test('checks the deadline before each package', async () => {
117+
const fetchImpl = vi.fn<FetchLike>(async () => {
118+
vi.setSystemTime(Date.now() + 5_000);
119+
return response(404);
120+
});
121+
122+
await expect(waitForNpmPackages(pendingPackages, options(fetchImpl))).rejects.toThrow(
123+
'Timed out after 5s waiting for npm propagation: first@1.2.3, second@1.2.3',
124+
);
125+
126+
expect(fetchImpl).toHaveBeenCalledTimes(1);
127+
expect(vi.getTimerCount()).toBe(0);
128+
});
129+
130+
test('aborts a stalled request at the deadline and skips later packages', async () => {
131+
const fetchImpl = vi.fn(stalledFetch);
132+
const result = expect(waitForNpmPackages(pendingPackages, options(fetchImpl))).rejects.toThrow(
133+
'Timed out after 5s waiting for npm propagation: first@1.2.3, second@1.2.3',
134+
);
135+
136+
await vi.advanceTimersByTimeAsync(5_000);
137+
await result;
138+
139+
expect(fetchImpl).toHaveBeenCalledTimes(1);
140+
expect(fetchImpl.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
141+
expect(vi.getTimerCount()).toBe(0);
142+
});
143+
});
144+
145+
test('parseNpmPackageSpec supports scoped and unscoped package names', () => {
146+
expect(parseNpmPackageSpec('@scope/pkg@1.2.3')).toEqual({
147+
name: '@scope/pkg',
148+
version: '1.2.3',
149+
});
150+
expect(parseNpmPackageSpec('pkg@1.2.3')).toEqual(pkg);
151+
expect(() => parseNpmPackageSpec('@scope/pkg')).toThrow('name@version');
152+
});

0 commit comments

Comments
 (0)