mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-28 02:53:15 -04:00
* feat: add pnpm docs command and home alias * chore: update manifests * fix: address review comments for pnpm docs command - Remove 'docs' and 'home' from NOT_IMPLEMENTED_COMMANDS to prevent not-implemented handlers from overriding the real implementations - Change fallback URL from npmjs.com to npmx.dev - Remove bugs URL as a docs fallback (it's an issue tracker, not docs) - Fix ESM mock in tests: use jest.unstable_mockModule instead of jest.mock * refactor: use open package for browser opening in promptBrowserOpen Replace the hand-rolled platform-specific execFile logic with the open npm package, which handles WSL, Docker-in-WSL, and Windows edge cases better. This removes the execFile dependency injection from promptBrowserOpen, OtpContext, LoginContext, and SharedContext. * fix: address copilot review comments - docs: use www.npmjs.com fallback (not npmx.dev) and validate homepage URL is http(s) before opening - promptBrowserOpen: validate authUrl protocol before passing to open(), and guard against synchronous throws from open() * fix: restore npmx.dev fallback for pnpm docs * chore: expand changeset with web-auth refactor impact --------- Co-authored-by: Zoltan Kochan <z@kochan.io>
263 lines
7.6 KiB
TypeScript
263 lines
7.6 KiB
TypeScript
import { jest } from '@jest/globals'
|
|
import type {
|
|
PromptBrowserOpenContext,
|
|
PromptBrowserOpenReadlineInterface,
|
|
} from '@pnpm/network.web-auth'
|
|
|
|
const mockOpen = jest.fn<(target: string) => Promise<unknown>>()
|
|
jest.unstable_mockModule('open', () => ({
|
|
default: mockOpen,
|
|
}))
|
|
|
|
const { promptBrowserOpen } = await import('@pnpm/network.web-auth')
|
|
|
|
function createDeferred<T> (): {
|
|
promise: Promise<T>
|
|
resolve: (value: T) => void
|
|
reject: (reason?: unknown) => void
|
|
} {
|
|
let resolve!: (value: T) => void
|
|
let reject!: (reason?: unknown) => void
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res
|
|
reject = rej
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
|
|
interface MockReadlineInterface extends PromptBrowserOpenReadlineInterface {
|
|
simulateEnterKeypress: () => void
|
|
}
|
|
|
|
const createMockReadlineInterface = (): MockReadlineInterface => {
|
|
let lineListener: (() => void) | undefined
|
|
return {
|
|
once: (_event: string, listener: () => void) => {
|
|
lineListener = listener
|
|
},
|
|
close: jest.fn<() => void>(),
|
|
simulateEnterKeypress: () => lineListener?.(),
|
|
}
|
|
}
|
|
|
|
type MockContextOverrides = Omit<Partial<PromptBrowserOpenContext>, 'process'> & {
|
|
process?: Partial<PromptBrowserOpenContext['process']>
|
|
}
|
|
|
|
const createMockContext = (overrides?: MockContextOverrides): PromptBrowserOpenContext => ({
|
|
globalInfo: () => {},
|
|
globalWarn: () => {},
|
|
...overrides,
|
|
process: {
|
|
stdin: { isTTY: true },
|
|
...overrides?.process,
|
|
},
|
|
})
|
|
|
|
beforeEach(() => {
|
|
mockOpen.mockReset()
|
|
mockOpen.mockResolvedValue(undefined)
|
|
})
|
|
|
|
describe('promptBrowserOpen', () => {
|
|
it('returns the poll result when poll completes before Enter keypress', async () => {
|
|
const mockRl = createMockReadlineInterface()
|
|
const context = createMockContext({
|
|
createReadlineInterface: () => mockRl,
|
|
})
|
|
|
|
const token = await promptBrowserOpen({
|
|
authUrl: 'https://example.com/auth',
|
|
context,
|
|
pollPromise: Promise.resolve('my-token'),
|
|
})
|
|
|
|
expect(token).toBe('my-token')
|
|
expect(mockRl.close).toHaveBeenCalled()
|
|
expect(mockOpen).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('opens browser via open package when Enter key is pressed before poll completes', async () => {
|
|
const mockRl = createMockReadlineInterface()
|
|
const pollDeferred = createDeferred<string>()
|
|
const context = createMockContext({
|
|
createReadlineInterface: () => mockRl,
|
|
})
|
|
|
|
const resultPromise = promptBrowserOpen({
|
|
authUrl: 'https://example.com/auth',
|
|
context,
|
|
pollPromise: pollDeferred.promise,
|
|
})
|
|
|
|
mockRl.simulateEnterKeypress()
|
|
|
|
await new Promise<void>(resolve => queueMicrotask(resolve))
|
|
|
|
expect(mockOpen).toHaveBeenCalledWith('https://example.com/auth')
|
|
|
|
pollDeferred.resolve('token-after-enter')
|
|
const token = await resultPromise
|
|
|
|
expect(token).toBe('token-after-enter')
|
|
expect(mockRl.close).toHaveBeenCalled()
|
|
})
|
|
|
|
it('warns and continues polling when open fails', async () => {
|
|
const mockRl = createMockReadlineInterface()
|
|
const pollDeferred = createDeferred<string>()
|
|
const globalWarn = jest.fn<(msg: string) => void>()
|
|
const globalInfo = jest.fn<(msg: string) => void>()
|
|
mockOpen.mockRejectedValue(new Error('xdg-open not found'))
|
|
const context = createMockContext({
|
|
createReadlineInterface: () => mockRl,
|
|
globalInfo,
|
|
globalWarn,
|
|
})
|
|
|
|
const resultPromise = promptBrowserOpen({
|
|
authUrl: 'https://example.com/auth',
|
|
context,
|
|
pollPromise: pollDeferred.promise,
|
|
})
|
|
|
|
mockRl.simulateEnterKeypress()
|
|
|
|
await new Promise<void>(resolve => queueMicrotask(resolve))
|
|
await new Promise<void>(resolve => queueMicrotask(resolve))
|
|
|
|
expect(globalWarn).toHaveBeenCalledWith(expect.stringContaining('xdg-open not found'))
|
|
expect(globalInfo).toHaveBeenCalledWith('Please open the URL shown above manually.')
|
|
|
|
pollDeferred.resolve('tok')
|
|
expect(await resultPromise).toBe('tok')
|
|
})
|
|
|
|
it('warns and falls back to plain poll when createReadlineInterface throws', async () => {
|
|
const globalWarn = jest.fn<(msg: string) => void>()
|
|
const context = createMockContext({
|
|
createReadlineInterface: () => {
|
|
throw new Error('setRawMode not supported')
|
|
},
|
|
globalWarn,
|
|
})
|
|
|
|
const token = await promptBrowserOpen({
|
|
authUrl: 'https://example.com/auth',
|
|
context,
|
|
pollPromise: Promise.resolve('fallback-token'),
|
|
})
|
|
|
|
expect(token).toBe('fallback-token')
|
|
expect(globalWarn).toHaveBeenCalledWith(expect.stringContaining('setRawMode not supported'))
|
|
expect(mockOpen).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('falls back to plain poll when createReadlineInterface is not provided', async () => {
|
|
const context = createMockContext()
|
|
|
|
const token = await promptBrowserOpen({
|
|
authUrl: 'https://example.com/auth',
|
|
context,
|
|
pollPromise: Promise.resolve('plain-token'),
|
|
})
|
|
|
|
expect(token).toBe('plain-token')
|
|
})
|
|
|
|
it('falls back to plain poll when stdin is not a TTY', async () => {
|
|
const context = createMockContext({
|
|
createReadlineInterface: createMockReadlineInterface,
|
|
process: { stdin: { isTTY: false } },
|
|
})
|
|
|
|
const token = await promptBrowserOpen({
|
|
authUrl: 'https://example.com/auth',
|
|
context,
|
|
pollPromise: Promise.resolve('plain-token'),
|
|
})
|
|
|
|
expect(token).toBe('plain-token')
|
|
})
|
|
|
|
it('shows the press-Enter message', async () => {
|
|
const mockRl = createMockReadlineInterface()
|
|
const globalInfo = jest.fn<(msg: string) => void>()
|
|
const context = createMockContext({
|
|
createReadlineInterface: () => mockRl,
|
|
globalInfo,
|
|
})
|
|
|
|
await promptBrowserOpen({
|
|
authUrl: 'https://example.com/auth',
|
|
context,
|
|
pollPromise: Promise.resolve('tok'),
|
|
})
|
|
|
|
expect(globalInfo).toHaveBeenCalledWith('Press ENTER to open the URL in your browser.')
|
|
})
|
|
|
|
it.each([
|
|
['javascript:alert(1)'],
|
|
['file:///etc/passwd'],
|
|
['not a url'],
|
|
])('does not open browser for non-http(s) authUrl %s', async (authUrl) => {
|
|
const mockRl = createMockReadlineInterface()
|
|
const pollDeferred = createDeferred<string>()
|
|
const context = createMockContext({
|
|
createReadlineInterface: () => mockRl,
|
|
})
|
|
|
|
const resultPromise = promptBrowserOpen({
|
|
authUrl,
|
|
context,
|
|
pollPromise: pollDeferred.promise,
|
|
})
|
|
|
|
pollDeferred.resolve('tok')
|
|
expect(await resultPromise).toBe('tok')
|
|
expect(mockOpen).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('continues polling when open throws synchronously', async () => {
|
|
const mockRl = createMockReadlineInterface()
|
|
const pollDeferred = createDeferred<string>()
|
|
const globalWarn = jest.fn<(msg: string) => void>()
|
|
mockOpen.mockImplementation(() => {
|
|
throw new Error('sync failure')
|
|
})
|
|
const context = createMockContext({
|
|
createReadlineInterface: () => mockRl,
|
|
globalWarn,
|
|
})
|
|
|
|
const resultPromise = promptBrowserOpen({
|
|
authUrl: 'https://example.com/auth',
|
|
context,
|
|
pollPromise: pollDeferred.promise,
|
|
})
|
|
|
|
mockRl.simulateEnterKeypress()
|
|
|
|
expect(globalWarn).toHaveBeenCalledWith(expect.stringContaining('sync failure'))
|
|
|
|
pollDeferred.resolve('tok')
|
|
expect(await resultPromise).toBe('tok')
|
|
})
|
|
|
|
it('cleans up when poll rejects', async () => {
|
|
const mockRl = createMockReadlineInterface()
|
|
const context = createMockContext({
|
|
createReadlineInterface: () => mockRl,
|
|
})
|
|
|
|
await expect(promptBrowserOpen({
|
|
authUrl: 'https://example.com/auth',
|
|
context,
|
|
pollPromise: Promise.reject(new Error('timeout')),
|
|
})).rejects.toThrow('timeout')
|
|
|
|
expect(mockRl.close).toHaveBeenCalled()
|
|
})
|
|
})
|