* docs(agents): add the documentation and comment principles Import the principles that transfer from KSXGitHub/perfectionist's contributor guide, plus four of our own: concise and non-repeating text, structured text over prose, removal over addition when reviewing or fixing nitpicks, and a definition of the user-facing text that must not name implementation details. The root guide's "Comments" section gains "Register", "Prose that cannot go stale", "User-facing text", and "Reviewing documentation and comments". The pacquet style guide adds the Rust specifics (clap and napi doc comments are user-facing; a default is documented on the field, not the variant). DOCUMENTATION.md gains the renderer and link table for each markdown surface. The guides themselves are brought in line: counts over growable lists become plain references, a quoted heading becomes an anchor link, and the size limits are stated once, in the style guide, instead of three times. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYTBQo8HY7xD2iz6hjo1CW * docs: keep user-facing text free of implementation names The `@pnpm/napi` README and type declarations named the Rust engine, the in-repo package name, and TypeScript internals (`filterPkgsDiff`, `nameFormatter`) that a user of the addon cannot reach. The pnpmfile page in both docs trees explained a limitation by the IPC mechanism behind it. Four changesets named the release line or implementation, which the package targets already express. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYTBQo8HY7xD2iz6hjo1CW * docs(crates): cut restating and stale comments in pack, modules-yaml and nine more crates Apply the comment principles to pack, versioning, pnpr-client, package-is-installable, fs-packlist, workspace-task-scheduler, engine-runtime-node-resolver, exportable-manifest, modules-yaml, pnpr-registry, and pnpr-auth. Removed: call-site comments that repeat the callee's doc, module rosters of exports that the `pub use` lines already list, "not yet implemented" notes for behaviour that exists (`beforePacking`, `hoistedLocations`), body narration, and history framing. Comment-only; no code changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYTBQo8HY7xD2iz6hjo1CW * docs(crates): cut restating and stale comments in the small resolver, catalog and pnpr crates Apply the comment principles to the local, tarball, default, deno and jsr resolvers, the catalog crates, detect-libc, config-parse-overrides, env-replace, git-utils, matcher, diagnostics, and the pnpr policy, cargo, error, osv and fixture crates. Removed: parentheticals that repeat a `#[diagnostic(code)]` attribute, counts over lists of variants, callers and entry points, rosters of callers, doc paragraphs that repeat a sibling's or the module's text, comments narrating the match arms or condition below them, and history framing. The `pnpr-prepare --help` text no longer names the test harness. Comment-only; no code changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYTBQo8HY7xD2iz6hjo1CW * docs(crates): cut restating and stale comments in publish, workspace, registry and graph-hasher Apply the comment principles to publish, auth-commands, registry, workspace, lockfile-verification, default-reporter, the git resolver, deps-inspection, hooks, graph-hasher, package-manifest, and the pnpr shared-artifacts and route crates. Removed: a module roster of every export, counts over lists, plan notes for work that landed ("Phase 5", "Phase 6"), call-site comments repeating the callee's doc, docs narrating match arms, a default claim on a `#[default]` variant, a misattached doc, and history framing. Comment-only; no code changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYTBQo8HY7xD2iz6hjo1CW * docs(crates): cut restating comments in napi, reporter and pnpr crates Apply the comment principles to napi, reporter, resolving-resolver-base, real-hoist, git-fetcher, workspace-manifest-writer, and the pnpr config and storage crates. Removed: "Mirrors X in index.d.ts" sentences on `#[napi]` items and other implementation names in addon-facing docs, stale "not emitted yet" and "when it lands" notes for behaviour that exists, step-by-step comments narrating the body, emit-site and caller rosters, docs duplicated from a sibling or the module, a doc attached to the wrong item, and history framing. Comment-only; no code changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYTBQo8HY7xD2iz6hjo1CW * docs: cut restating and stale comments across the remaining crates and pnpm11 Apply the comment principles to the rest of the Rust workspace and to the TypeScript CLI: the cli crate and its clap definitions, package-manager, config, patching, the deps resolver, lockfile, pnpr, the npm resolver, tarball, network, executor, cmd-shim, the benchmark and fixture tooling, and the pnpm11 packages. Removed: comments narrating the body, docs repeating a callee's, a sibling's or the module's text, counts over growable lists, rosters of callers and exports, "not yet implemented" notes for behaviour that exists, history framing, and `@param` lines restating a signature. One exception to comment-only: in `pnpr/crates/pnpr/src/resolver/resolve.rs` the `Workspace` struct sat inside the doc block written for `resolve`, so the struct moved above it and each doc now attaches to its own item. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYTBQo8HY7xD2iz6hjo1CW * docs(crates): cut body narration and duplicated docs in resolver, fs and package-manager Removed: a numbered "Sequence:" list narrating `ensure_file`'s body and the paragraph restating its backoff constants, the stress-test module doc's roster of its own tests, refactor-history paragraphs in the peer walk's finalize pass, call-site and caller-side copies of a callee's doc (the `optional` AND-fold stays documented on `register_seeded_package` and on `ResolvedPackage`), a verbatim quotation of a TypeScript test's expected output in the pnpr search module doc, and field and digest-input rosters in the package-manager. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYTBQo8HY7xD2iz6hjo1CW * docs(deps-restorer): cut plan references and re-attach misplaced docs Removed the "slice 4" plan references and the comments narrating the bodies they sat above. The `Auto` downgrade-chain doc moved from `copy_and_log` to `auto_link`, the function it describes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYTBQo8HY7xD2iz6hjo1CW * docs(deps-restorer): cut the remaining body narration in the hoist path Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYTBQo8HY7xD2iz6hjo1CW * docs: cut the last body narration and a history clause in the cmd-shim test `build_graph.rs` kept a `patches` parameter doc that re-derived what `BuildOptions::patches` documents. The PowerShell-arguments test on main said the arguments survived "before the shim ended its batch context too", which describes the change rather than the behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYTBQo8HY7xD2iz6hjo1CW * docs(cmd-shim): point the PowerShell-shim link at its current module Main moved `wants_powershell_shim` into `windows_shim_policy`, so the shim writer's link to it no longer resolved and `cargo doc` failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYTBQo8HY7xD2iz6hjo1CW * docs(cli): link the fixture the ignored-build setup installs The shortened doc named `workspace_with_unapproved_build` in backticks, which `perfectionist::bare_identifier_reference` rejects for an item that resolves in scope. It is an intra-doc link now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYTBQo8HY7xD2iz6hjo1CW * docs(crates): restore two contracts and fix four dangling references A review of this branch's sweep flagged seven places. Six needed a change: - `from_ini` documents again that a relative `cafile=` resolves against `npmrc_dir`, a precondition its callers have to honour. - `hash_object` documents again that the private serializer's bytestream has to match pnpm 11's, because the digest is persisted and read by both. - The hooked-manifests note no longer says "above" of a gate that lives in another module. - The store-dir race test states its synchronization contract instead of labelling it "Post-Option-C". - `GraphNodeHashOptions.nodeVersion` keeps `undefined` as a literal. - The pnpr streaming module drops the sentence fragment that introduced its list. The seventh, a `beforePacking` limitation in exportable-manifest, stays removed: the note said the pnpmfile bridge cannot supply that hook, and `pnpm_pack::lifecycle::apply_before_packing` has been running it since before this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYTBQo8HY7xD2iz6hjo1CW * docs(cli): say in `pnpm why --help` that `--exclude-peers` is ignored Demoting the caveat to an ordinary comment took it out of the help, which then advertised a flag that does nothing: `WhyArgs::run` never reads `exclude_peers`, and pnpm 11's `why` handler never forwards its `excludePeers` either. The doc comment states that behavior in the words a user sees, and the parity reason stays in a comment beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYTBQo8HY7xD2iz6hjo1CW * docs(cli): drop the doc comment that described the wrong module The comment described the `pnpm_deps_inspection as deps_tree` re-export above it, but a doc comment attaches to the item that follows, so rustdoc showed it as the description of `mod dispatch`. The `use` item already names the alias, so the comment goes rather than moving. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYTBQo8HY7xD2iz6hjo1CW --------- Co-authored-by: Claude <noreply@anthropic.com>
687 lines
22 KiB
TypeScript
687 lines
22 KiB
TypeScript
import { describe, expect, it } from '@jest/globals'
|
|
import {
|
|
pollForWebAuthToken,
|
|
type WebAuthContext,
|
|
type WebAuthFetchOptions,
|
|
type WebAuthFetchResponse,
|
|
type WebAuthFetchResponseBody,
|
|
WebAuthTimeoutError,
|
|
} from '@pnpm/network.web-auth'
|
|
|
|
function createMockResponse (init: {
|
|
ok: boolean
|
|
status: number
|
|
json?: unknown
|
|
headers?: WebAuthFetchResponse['headers']
|
|
}): WebAuthFetchResponse {
|
|
let bodyConsumed = false
|
|
return {
|
|
ok: init.ok,
|
|
status: init.status,
|
|
json: async () => {
|
|
if (bodyConsumed) throw new Error('Unexpected double consumption of response body')
|
|
bodyConsumed = true
|
|
return init.json ?? {}
|
|
},
|
|
headers: init.headers ?? {
|
|
get: name => {
|
|
throw new Error(`Unexpected call to headers.get: ${name}`)
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
function createRetryAfterHeaders (retryAfter: string): WebAuthFetchResponse['headers'] {
|
|
return { get: (name: string) => name === 'retry-after' ? retryAfter : null }
|
|
}
|
|
|
|
interface MockBody extends WebAuthFetchResponseBody {
|
|
cancelled: boolean
|
|
}
|
|
|
|
function createMockBody (text: string, chunkSize = 1024): MockBody {
|
|
const bytes = new TextEncoder().encode(text)
|
|
let offset = 0
|
|
const body: MockBody = {
|
|
cancelled: false,
|
|
cancel: async () => {
|
|
body.cancelled = true
|
|
},
|
|
getReader: () => ({
|
|
read: async () => {
|
|
if (body.cancelled || offset >= bytes.length) return { done: true }
|
|
const value = bytes.subarray(offset, offset + chunkSize)
|
|
offset += chunkSize
|
|
return { done: false, value }
|
|
},
|
|
cancel: async () => {
|
|
body.cancelled = true
|
|
},
|
|
}),
|
|
}
|
|
return body
|
|
}
|
|
|
|
function createMockStreamResponse (init: {
|
|
ok: boolean
|
|
status: number
|
|
body: MockBody | null
|
|
contentLength?: number
|
|
}): WebAuthFetchResponse {
|
|
return {
|
|
ok: init.ok,
|
|
status: init.status,
|
|
body: init.body,
|
|
json: async () => {
|
|
throw new Error('Unexpected call to json() on a response exposing a body stream')
|
|
},
|
|
headers: {
|
|
get: name =>
|
|
name === 'content-length' && init.contentLength != null ? String(init.contentLength) : null,
|
|
},
|
|
}
|
|
}
|
|
|
|
const createMockContext = (overrides?: Partial<WebAuthContext>): WebAuthContext => ({
|
|
Date: { now: () => 0 },
|
|
setTimeout: (cb: () => void) => cb(),
|
|
fetch: async () => createMockResponse({
|
|
ok: false,
|
|
status: 404,
|
|
}),
|
|
...overrides,
|
|
})
|
|
|
|
const fetchOptions: WebAuthFetchOptions = { method: 'GET' }
|
|
|
|
describe('pollForWebAuthToken', () => {
|
|
it('returns token when doneUrl responds with 200 and token', async () => {
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount < 3) {
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 202,
|
|
headers: { get: () => '1' },
|
|
})
|
|
}
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 200,
|
|
json: { token: 'web-token-123' },
|
|
})
|
|
},
|
|
})
|
|
const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
expect(token).toBe('web-token-123')
|
|
expect(fetchCallCount).toBe(3)
|
|
})
|
|
|
|
it('passes doneUrl and fetchOptions to fetch', async () => {
|
|
const capturedArgs: Array<{ url: string, options: WebAuthFetchOptions }> = []
|
|
const opts: WebAuthFetchOptions = {
|
|
method: 'GET',
|
|
timeout: 5000,
|
|
retry: { retries: 3 },
|
|
}
|
|
const context = createMockContext({
|
|
fetch: async (url: string, options: WebAuthFetchOptions): Promise<WebAuthFetchResponse> => {
|
|
capturedArgs.push({ url, options })
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 200,
|
|
json: { token: 'tok' },
|
|
})
|
|
},
|
|
})
|
|
await pollForWebAuthToken({ context, doneUrl: 'https://registry.example.com/done', fetchOptions: opts })
|
|
expect(capturedArgs).toEqual([{ url: 'https://registry.example.com/done', options: opts }])
|
|
})
|
|
|
|
it('respects Retry-After header when polling', async () => {
|
|
const setTimeoutDelays: number[] = []
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
setTimeout: (cb: () => void, ms: number) => {
|
|
setTimeoutDelays.push(ms)
|
|
cb()
|
|
},
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount === 1) {
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 202,
|
|
headers: createRetryAfterHeaders('5'),
|
|
})
|
|
}
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 200,
|
|
json: { token: 'tok' },
|
|
})
|
|
},
|
|
})
|
|
await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
// First setTimeout is the default 1s poll interval,
|
|
// second is the additional delay (5s Retry-After minus the 1s already waited),
|
|
// third is the default 1s poll interval for the next iteration.
|
|
expect(setTimeoutDelays).toStrictEqual([1000, 4000, 1000])
|
|
})
|
|
|
|
it('ignores Retry-After when value is not a finite number', async () => {
|
|
const setTimeoutDelays: number[] = []
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
setTimeout: (cb: () => void, ms: number) => {
|
|
setTimeoutDelays.push(ms)
|
|
cb()
|
|
},
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount === 1) {
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 202,
|
|
headers: { get: () => 'not-a-number' },
|
|
})
|
|
}
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 200,
|
|
json: { token: 'tok' },
|
|
})
|
|
},
|
|
})
|
|
await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
// Only the default 1s poll intervals, no additional Retry-After delay.
|
|
expect(setTimeoutDelays).toStrictEqual([1000, 1000])
|
|
})
|
|
|
|
it('ignores Retry-After when value is null', async () => {
|
|
const setTimeoutDelays: number[] = []
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
setTimeout: (cb: () => void, ms: number) => {
|
|
setTimeoutDelays.push(ms)
|
|
cb()
|
|
},
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount === 1) {
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 202,
|
|
headers: { get: () => null },
|
|
})
|
|
}
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 200,
|
|
json: { token: 'tok' },
|
|
})
|
|
},
|
|
})
|
|
await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
expect(setTimeoutDelays).toStrictEqual([1000, 1000])
|
|
})
|
|
|
|
it('skips additional delay when Retry-After is less than poll interval', async () => {
|
|
const setTimeoutDelays: number[] = []
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
setTimeout: (cb: () => void, ms: number) => {
|
|
setTimeoutDelays.push(ms)
|
|
cb()
|
|
},
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount === 1) {
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 202,
|
|
headers: createRetryAfterHeaders('0.5'),
|
|
})
|
|
}
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 200,
|
|
json: { token: 'tok' },
|
|
})
|
|
},
|
|
})
|
|
await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
// Retry-After of 0.5s (500ms) is less than the 1s poll interval already waited,
|
|
// so no additional delay is added.
|
|
expect(setTimeoutDelays).toStrictEqual([1000, 1000])
|
|
})
|
|
|
|
it('caps Retry-After additional delay to remaining timeout', async () => {
|
|
let time = 0
|
|
const setTimeoutDelays: number[] = []
|
|
const context = createMockContext({
|
|
Date: { now: () => time },
|
|
setTimeout: (cb: () => void, ms: number) => {
|
|
setTimeoutDelays.push(ms)
|
|
time += ms
|
|
cb()
|
|
},
|
|
fetch: async (): Promise<WebAuthFetchResponse> => createMockResponse({
|
|
ok: true,
|
|
status: 202,
|
|
json: { token: 'tok' },
|
|
headers: createRetryAfterHeaders('60'),
|
|
}),
|
|
})
|
|
// Use a 10s timeout so the 60s Retry-After gets capped.
|
|
await expect(pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions, timeoutMs: 10_000 }))
|
|
.rejects.toBeInstanceOf(WebAuthTimeoutError)
|
|
// The first delay is the 1s poll interval. The additional delay from
|
|
// Retry-After (59s) should be capped to the remaining timeout (~9s).
|
|
expect(setTimeoutDelays[0]).toBe(1000)
|
|
expect(setTimeoutDelays[1]).toBeLessThanOrEqual(9000)
|
|
})
|
|
|
|
it('throws WebAuthTimeoutError when timeout expires during Retry-After wait', async () => {
|
|
let time = 0
|
|
const timeoutMs = 5000
|
|
const context = createMockContext({
|
|
Date: {
|
|
now: () => time,
|
|
},
|
|
setTimeout: (cb: () => void, ms: number) => {
|
|
time += ms
|
|
cb()
|
|
},
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
// After the 1s poll interval, time is 1000.
|
|
// Remaining is 4000. Retry-After is 100s, so additional is 99000,
|
|
// capped to 4000. After that wait, time = 5000, which equals timeout.
|
|
// Next iteration: now - startTime > timeoutMs → throw.
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 202,
|
|
headers: createRetryAfterHeaders('100'),
|
|
})
|
|
},
|
|
})
|
|
await expect(pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions, timeoutMs }))
|
|
.rejects.toMatchObject({ timeout: timeoutMs })
|
|
})
|
|
|
|
it('continues polling when fetch throws', async () => {
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount === 1) {
|
|
throw new Error('network failure')
|
|
}
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 200,
|
|
json: { token: 'tok' },
|
|
})
|
|
},
|
|
})
|
|
const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
expect(token).toBe('tok')
|
|
expect(fetchCallCount).toBe(2)
|
|
})
|
|
|
|
it('continues polling when response is not ok', async () => {
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount === 1) {
|
|
return createMockResponse({
|
|
ok: false,
|
|
status: 404,
|
|
})
|
|
}
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 200,
|
|
json: { token: 'tok' },
|
|
})
|
|
},
|
|
})
|
|
const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
expect(token).toBe('tok')
|
|
expect(fetchCallCount).toBe(2)
|
|
})
|
|
|
|
it('continues polling when response.json() throws', async () => {
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount === 1) {
|
|
return {
|
|
headers: { get: () => null },
|
|
json: async () => {
|
|
throw new Error('invalid json')
|
|
},
|
|
ok: true,
|
|
status: 200,
|
|
}
|
|
}
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 200,
|
|
json: { token: 'tok' },
|
|
})
|
|
},
|
|
})
|
|
const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
expect(token).toBe('tok')
|
|
expect(fetchCallCount).toBe(2)
|
|
})
|
|
|
|
it('continues polling when response body has no token', async () => {
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount === 1) {
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 200,
|
|
json: { something: 'else' },
|
|
})
|
|
}
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 200,
|
|
json: { token: 'tok' },
|
|
})
|
|
},
|
|
})
|
|
const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
expect(token).toBe('tok')
|
|
expect(fetchCallCount).toBe(2)
|
|
})
|
|
|
|
it('continues polling when token is empty string', async () => {
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount === 1) {
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 200,
|
|
json: { token: '' },
|
|
})
|
|
}
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 200,
|
|
json: { token: 'real-tok' },
|
|
})
|
|
},
|
|
})
|
|
const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
expect(token).toBe('real-tok')
|
|
expect(fetchCallCount).toBe(2)
|
|
})
|
|
|
|
it('throws WebAuthTimeoutError after timeout', async () => {
|
|
let time = 0
|
|
const context = createMockContext({
|
|
Date: { now: () => time },
|
|
setTimeout: (cb: () => void) => {
|
|
time += 6 * 60 * 1000 // Jump past timeout
|
|
cb()
|
|
},
|
|
fetch: async (): Promise<WebAuthFetchResponse> => createMockResponse({
|
|
ok: true,
|
|
status: 202,
|
|
headers: { get: () => null },
|
|
}),
|
|
})
|
|
await expect(pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions }))
|
|
.rejects.toBeInstanceOf(WebAuthTimeoutError)
|
|
})
|
|
|
|
it('uses custom timeout value', async () => {
|
|
let time = 0
|
|
const customTimeoutMs = 3000
|
|
const context = createMockContext({
|
|
Date: { now: () => time },
|
|
setTimeout: (cb: () => void) => {
|
|
time += 2000
|
|
cb()
|
|
},
|
|
fetch: async (): Promise<WebAuthFetchResponse> => createMockResponse({
|
|
ok: true,
|
|
status: 202,
|
|
headers: { get: () => null },
|
|
}),
|
|
})
|
|
await expect(pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions, timeoutMs: customTimeoutMs }))
|
|
.rejects.toMatchObject({ timeout: customTimeoutMs })
|
|
})
|
|
|
|
it('recovers after multiple consecutive fetch errors', async () => {
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount <= 5) {
|
|
throw new Error(`failure #${fetchCallCount}`)
|
|
}
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 200,
|
|
json: { token: 'recovered' },
|
|
})
|
|
},
|
|
})
|
|
const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
expect(token).toBe('recovered')
|
|
expect(fetchCallCount).toBe(6)
|
|
})
|
|
|
|
it('waits pollIntervalMs before each fetch call', async () => {
|
|
const setTimeoutDelays: number[] = []
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
setTimeout: (cb: () => void, ms: number) => {
|
|
setTimeoutDelays.push(ms)
|
|
cb()
|
|
},
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount < 4) {
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 202,
|
|
headers: { get: () => null },
|
|
})
|
|
}
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 200,
|
|
json: { token: 'tok' },
|
|
})
|
|
},
|
|
})
|
|
await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
// Each iteration waits 1000ms before fetching.
|
|
expect(setTimeoutDelays).toStrictEqual([1000, 1000, 1000, 1000])
|
|
})
|
|
|
|
it('throws WebAuthTimeoutError immediately when remaining time is zero during Retry-After', async () => {
|
|
let time = 0
|
|
const timeoutMs = 2000
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
Date: { now: () => time },
|
|
setTimeout: (cb: () => void, ms: number) => {
|
|
time += ms
|
|
cb()
|
|
},
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount === 1) {
|
|
// After poll interval (1s), time = 1000, remaining = 1000.
|
|
// Retry-After = 10s → additional = 9000 > remaining.
|
|
// Capped to remaining (1000). After that wait, time = 2000.
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 202,
|
|
headers: createRetryAfterHeaders('10'),
|
|
})
|
|
}
|
|
// This second fetch still returns 202. The timeout check is `>`, so
|
|
// 2000 > 2000 is false; it waits another 1s, then 3000 > 2000 is true.
|
|
return createMockResponse({
|
|
ok: true,
|
|
status: 202,
|
|
headers: { get: () => null },
|
|
})
|
|
},
|
|
})
|
|
await expect(pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions, timeoutMs }))
|
|
.rejects.toMatchObject({ timeout: timeoutMs })
|
|
})
|
|
|
|
it('reads the token through the body stream when the response exposes one', async () => {
|
|
const body = createMockBody(JSON.stringify({ token: 'streamed-token' }))
|
|
const context = createMockContext({
|
|
fetch: async (): Promise<WebAuthFetchResponse> => createMockStreamResponse({
|
|
ok: true,
|
|
status: 200,
|
|
body,
|
|
}),
|
|
})
|
|
const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
expect(token).toBe('streamed-token')
|
|
})
|
|
|
|
it('continues polling when the streamed body exceeds the size cap', async () => {
|
|
// Valid JSON overall, but longer than the 64 KiB cap: the read must stop
|
|
// at the cap and discard the body rather than buffer it whole.
|
|
const oversized = createMockBody(JSON.stringify({ token: 'x'.repeat(65 * 1024) }))
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount === 1) {
|
|
return createMockStreamResponse({ ok: true, status: 200, body: oversized })
|
|
}
|
|
return createMockStreamResponse({
|
|
ok: true,
|
|
status: 200,
|
|
body: createMockBody(JSON.stringify({ token: 'tok' })),
|
|
})
|
|
},
|
|
})
|
|
const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
expect(token).toBe('tok')
|
|
expect(fetchCallCount).toBe(2)
|
|
expect(oversized.cancelled).toBe(true)
|
|
})
|
|
|
|
it('skips reading the body when content-length exceeds the size cap', async () => {
|
|
const oversized = createMockBody(JSON.stringify({ token: 'tok' }))
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount === 1) {
|
|
return createMockStreamResponse({
|
|
ok: true,
|
|
status: 200,
|
|
body: oversized,
|
|
contentLength: 65 * 1024,
|
|
})
|
|
}
|
|
return createMockStreamResponse({
|
|
ok: true,
|
|
status: 200,
|
|
body: createMockBody(JSON.stringify({ token: 'tok' })),
|
|
})
|
|
},
|
|
})
|
|
const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
expect(token).toBe('tok')
|
|
expect(fetchCallCount).toBe(2)
|
|
expect(oversized.cancelled).toBe(true)
|
|
})
|
|
|
|
it('continues polling when a streamed body is not valid JSON', async () => {
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount === 1) {
|
|
return createMockStreamResponse({
|
|
ok: true,
|
|
status: 200,
|
|
body: createMockBody('not json'),
|
|
})
|
|
}
|
|
return createMockStreamResponse({
|
|
ok: true,
|
|
status: 200,
|
|
body: createMockBody(JSON.stringify({ token: 'tok' })),
|
|
})
|
|
},
|
|
})
|
|
const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
expect(token).toBe('tok')
|
|
expect(fetchCallCount).toBe(2)
|
|
})
|
|
|
|
it('continues polling when the response body is null', async () => {
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount === 1) {
|
|
return createMockStreamResponse({ ok: true, status: 200, body: null })
|
|
}
|
|
return createMockStreamResponse({
|
|
ok: true,
|
|
status: 200,
|
|
body: createMockBody(JSON.stringify({ token: 'tok' })),
|
|
})
|
|
},
|
|
})
|
|
const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
expect(token).toBe('tok')
|
|
expect(fetchCallCount).toBe(2)
|
|
})
|
|
|
|
it('cancels the body of non-ok and 202 responses', async () => {
|
|
const notOkBody = createMockBody('an error page')
|
|
const stillWaitingBody = createMockBody('{}')
|
|
let fetchCallCount = 0
|
|
const context = createMockContext({
|
|
fetch: async (): Promise<WebAuthFetchResponse> => {
|
|
fetchCallCount++
|
|
if (fetchCallCount === 1) {
|
|
return createMockStreamResponse({ ok: false, status: 404, body: notOkBody })
|
|
}
|
|
if (fetchCallCount === 2) {
|
|
return createMockStreamResponse({ ok: true, status: 202, body: stillWaitingBody })
|
|
}
|
|
return createMockStreamResponse({
|
|
ok: true,
|
|
status: 200,
|
|
body: createMockBody(JSON.stringify({ token: 'tok' })),
|
|
})
|
|
},
|
|
})
|
|
const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })
|
|
expect(token).toBe('tok')
|
|
expect(notOkBody.cancelled).toBe(true)
|
|
expect(stillWaitingBody.cancelled).toBe(true)
|
|
})
|
|
})
|