diff --git a/docs/dev.md b/docs/dev.md index 3a08f0397..5a159c2a4 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -112,6 +112,8 @@ When the dev server reloads or restarts it says what caused it, and a change to A dev server records itself in `nuxt.lock` inside your [build directory](/docs/directory-structure/nuxt), so a second `nuxt dev` for the same project reports the one already running instead of racing it for the port. Pass `--takeover` to stop it and start in its place, or set `NUXT_IGNORE_LOCK=1` to run a second server anyway (unsupported). +With `--open`, the prompt also offers to open the running server in your browser without stopping it, which is the default when that server was started in a terminal. `--open.url` is resolved against the running server's URL. + The same lock file is how [`nuxt curl`](/docs/api/commands/curl) and [`nuxt task`](/docs/api/commands/task) find the server to talk to. ## HTTPS diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index dc6be6091..9d1014c5a 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -186,12 +186,18 @@ const command = defineCommand({ const takeover = await beforeServing(() => takeOverServer(buildDir, { requestedPort: parsePort(listenOverrides.port), takeover: ctx.args.takeover, + open: listenOverrides.open, + openURL: listenOverrides.openURL, })) if (takeover.action === 'refused') { await teardownDevUI() logger.error(formatTakeoverRefusal(takeover.existing, takeover.reason)) process.exit(1) } + if (takeover.action === 'opened') { + await teardownDevUI() + process.exit(0) + } if (takeover.action === 'start-anyway') { process.env.NUXT_IGNORE_LOCK = '1' } diff --git a/packages/nuxt-cli/src/dev/listen.ts b/packages/nuxt-cli/src/dev/listen.ts index de0ebd440..2a395dfdc 100644 --- a/packages/nuxt-cli/src/dev/listen.ts +++ b/packages/nuxt-cli/src/dev/listen.ts @@ -647,38 +647,59 @@ export function resolveOpenCommand( /** How long a launcher has to fail before we assume the browser did open. */ const BROWSER_LAUNCH_TIMEOUT_MS = 3000 -export function openBrowser(url: string): void { +/** Whether {@link openBrowser} has a browser to launch in this environment. */ +export function canOpenBrowser(): boolean { + const resolved = resolveOpenCommand('') + return !!resolved && (resolved[0] !== 'xdg-open' || hasDisplayServer()) +} + +/** + * Open `url` in a browser. The returned promise resolves once the launcher has + * exited or had time to fail, after any failure has been reported. + */ +export function openBrowser(url: string): Promise { const resolved = resolveOpenCommand(url) if (!resolved) { - return + return Promise.resolve() } const [command, args] = resolved if (command === 'xdg-open' && !hasDisplayServer()) { logger.warn(`No browser is available in this environment. Open ${styleText('cyan', url)} manually.`) - return + return Promise.resolve() } - const onFailure = (error: unknown) => { - debug('Failed to open browser:', error) - logger.warn(`Could not open ${styleText('cyan', url)} in a browser.`) - } + return new Promise((resolve) => { + const onFailure = (error: unknown) => { + debug('Failed to open browser:', error) + logger.warn(`Could not open ${styleText('cyan', url)} in a browser.`) + resolve() + } - try { - const child = spawn(command, args, { stdio: 'ignore', detached: true }) - child.once('error', onFailure) - const onExit = (code: number | null) => { - if (code) { - onFailure(new Error(`\`${command}\` exited with code ${code}`)) + try { + const child = spawn(command, args, { stdio: 'ignore', detached: true }) + const onExit = (code: number | null) => { + if (code) { + onFailure(new Error(`\`${command}\` exited with code ${code}`)) + } + settle() + } + function settle() { + child.off('exit', onExit) + child.unref() + resolve() } + child.once('error', (error) => { + onFailure(error) + settle() + }) + child.once('exit', onExit) + // `BROWSER` may point at the browser itself rather than a launcher, in which + // case the process lives as long as the browser does and its eventual exit + // code says nothing about whether the URL opened. + setTimeout(settle, BROWSER_LAUNCH_TIMEOUT_MS).unref() } - child.once('exit', onExit) - // `BROWSER` may point at the browser itself rather than a launcher, in which - // case the process lives as long as the browser does and its eventual exit - // code says nothing about whether the URL opened. - setTimeout(() => child.off('exit', onExit), BROWSER_LAUNCH_TIMEOUT_MS).unref() - child.unref() - } - catch (error) { - onFailure(error) - } + catch (error) { + onFailure(error) + } + }) } diff --git a/packages/nuxt-cli/src/dev/takeover.ts b/packages/nuxt-cli/src/dev/takeover.ts index 118739874..7749d2abf 100644 --- a/packages/nuxt-cli/src/dev/takeover.ts +++ b/packages/nuxt-cli/src/dev/takeover.ts @@ -8,10 +8,12 @@ import { checkPort } from 'get-port-please' import { isCI } from 'std-env' import { restoreRawMode, withDirectStdout } from '../utils/console' +import { lockAppURL } from '../utils/dev-server' import { clearStaleLock, clearTakeover, isLockEnabled, isProcessAlive, markTakenOver, readLock } from '../utils/lockfile' import { logger } from '../utils/logger' import { withUserAttention } from '../utils/startup-clock' import { isInteractiveSession } from '../utils/stdout' +import { canOpenBrowser, openBrowser, resolveOpenURL } from './listen' import { DEV_SHUTDOWN_TIMEOUT_MS } from './shutdown' /** How long a `SIGKILL`ed process has to disappear before we give up. */ @@ -39,8 +41,10 @@ export type TakeoverResult | { action: 'refused', existing: LockInfo, reason: TakeoverRefusalReason } /** The user chose to run a second server anyway, without the lock. */ | { action: 'start-anyway', existing: LockInfo } + /** The user opened the running server in a browser; the caller should exit cleanly. */ + | { action: 'opened', existing: LockInfo } -export type TakeoverChoice = 'takeover' | 'abort' | 'start-anyway' +export type TakeoverChoice = 'takeover' | 'abort' | 'open' | 'start-anyway' type ServerCommand = 'dev' | 'preview' @@ -66,8 +70,15 @@ export interface TakeoverOptions { takeover?: boolean /** Whether this invocation can prompt. Defaults to the current terminal. */ interactive?: boolean - /** Overridable so the decision table can be tested without a TTY. */ - prompt?: (existing: LockInfo, defaultChoice: TakeoverChoice) => Promise + /** `--open`; offers to open the running server instead of starting. */ + open?: boolean + /** `--open.url`; resolved against the running server's URL. */ + openURL?: string + /** + * Overridable so the decision table can be tested without a TTY. `openURL` is + * what the `open` choice would open, and is absent when it is unavailable. + */ + prompt?: (existing: LockInfo, defaultChoice: TakeoverChoice, openURL: string | undefined) => Promise /** How long to wait for the outgoing server, in ms. Overridable for tests. */ timeouts?: { graceful?: number, force?: number } } @@ -142,12 +153,25 @@ async function resolveTakeover(lockDir: string, options: TakeoverOptions): Promi } else { const prompt = options.prompt ?? promptForTakeover + const openURL = options.open ? resolveBrowserURL(existing, options.openURL) : undefined // Nothing ends an interactive session on a bare enter, so the default // depends on whether a person is watching the other server. - const choice = await withUserAttention(() => prompt(existing, existing.interactive ? 'abort' : 'takeover')) + const defaultChoice = !existing.interactive ? 'takeover' : openURL ? 'open' : 'abort' + const choice = await withUserAttention(() => prompt(existing, defaultChoice, openURL)) if (choice === 'abort') { return { action: 'refused', existing, reason: 'declined' } } + if (choice === 'open') { + if (!openURL) { + return { action: 'refused', existing, reason: 'declined' } + } + const gone = await checkHolderGone(lockDir, existing) + if (gone) { + return gone + } + await openBrowser(openURL) + return { action: 'opened', existing } + } if (choice === 'start-anyway') { if (existing.command === 'dev') { logger.warn(`Starting a second dev server: both will write to ${styleText('cyan', lockDir)}, which is unsupported and may corrupt the build.`) @@ -161,16 +185,12 @@ async function resolveTakeover(lockDir: string, options: TakeoverOptions): Promi } async function performTakeover(lockDir: string, existing: LockInfo, timeouts: TakeoverOptions['timeouts'] = {}): Promise { - const port = existing.port! - const portFree = await isPortFree(port, existing.hostname) - if (!matchesLock(readLock(lockDir), existing)) { - return { action: 'none' } - } - if (!isProcessAlive(existing.pid) || portFree) { - clearStaleLock(lockDir, existing) - return { action: 'stale' } + const gone = await checkHolderGone(lockDir, existing) + if (gone) { + return gone } + const port = existing.port! const label = describeServer(existing) const startedAt = new Date(existing.startedAt).toLocaleTimeString() const progress = startProgress(`Taking over the ${label} on port ${port} (PID ${existing.pid}, started ${startedAt})`) @@ -213,6 +233,21 @@ async function performTakeover(lockDir: string, existing: LockInfo, timeouts: Ta return { action: 'refused', existing, reason: 'timeout' } } +/** + * Recheck a holder after waiting on the user: `none` if its lock has been + * replaced, `stale` (with the lock dropped) if the server has gone. + */ +async function checkHolderGone(lockDir: string, existing: LockInfo): Promise { + const portFree = await isPortFree(existing.port!, existing.hostname) + if (!matchesLock(readLock(lockDir), existing)) { + return { action: 'none' } + } + if (!isProcessAlive(existing.pid) || portFree) { + clearStaleLock(lockDir, existing) + return { action: 'stale' } + } +} + function matchesLock(current: LockInfo | undefined, existing: LockInfo): boolean { return current?.pid === existing.pid && current.startedAt === existing.startedAt @@ -293,15 +328,22 @@ function describeLocation(existing: LockInfo): string { return existing.url || 'starting up (no URL yet)' } -async function promptForTakeover(existing: LockInfo, defaultChoice: TakeoverChoice): Promise { +function resolveBrowserURL(existing: LockInfo, target: string | undefined): string | undefined { + const url = canOpenBrowser() ? lockAppURL(existing) : undefined + return url && target ? resolveOpenURL(target, url) : url +} + +async function promptForTakeover(existing: LockInfo, defaultChoice: TakeoverChoice, openURL: string | undefined): Promise { logger.info(`A Nuxt ${describeServer(existing)} is already running here (PID ${existing.pid}, ${describeLocation(existing)}).`) + const andOpen = openURL ? ' and open in browser' : '' const choice = await select({ message: 'What would you like to do?', initialValue: defaultChoice, options: [ - { value: 'takeover', label: `Take over port ${existing.port}`, hint: 'stops the running server (--takeover)' }, + { value: 'takeover', label: `Take over port ${existing.port}${andOpen}`, hint: 'stops the running server (--takeover)' }, + ...(openURL ? [{ value: 'open' as const, label: `Do not start, open ${openURL} in browser`, hint: 'keeps the running server' }] : []), { value: 'abort', label: 'Do not start', hint: '--no-takeover' }, - { value: 'start-anyway', label: 'Start anyway', hint: serverKind(existing).startAnywayHint }, + { value: 'start-anyway', label: `Start anyway${andOpen}`, hint: serverKind(existing).startAnywayHint }, ], }) restoreRawMode() diff --git a/packages/nuxt-cli/src/dev/utils.ts b/packages/nuxt-cli/src/dev/utils.ts index 4a3217116..33c6ff676 100644 --- a/packages/nuxt-cli/src/dev/utils.ts +++ b/packages/nuxt-cli/src/dev/utils.ts @@ -1060,9 +1060,7 @@ export class NuxtDevServer extends EventEmitter { const https = (httpsEnabled ?? !!nuxtConfig.devServer?.https) && defu(typeof overrides.https === 'object' ? overrides.https : {}, httpsFromConfig) - const baseURL = nuxtConfig.app?.baseURL?.startsWith?.('./') - ? nuxtConfig.app.baseURL.slice(1) - : nuxtConfig.app?.baseURL + const baseURL = resolveAppBaseURL(nuxtConfig.app?.baseURL) return { ...overrides, @@ -1243,6 +1241,7 @@ export class NuxtDevServer extends EventEmitter { port: addr.port, hostname: addr.address, url: serverUrl, + baseURL: resolveAppBaseURL(this.#currentNuxt.options.app?.baseURL), }) this.#progress.setReady() @@ -1386,6 +1385,10 @@ function isInteractive(): boolean { return !!process.stdout.isTTY || !!process.env.__NUXT_DEV_PIPED_TTY__ } +function resolveAppBaseURL(baseURL: string | undefined): string | undefined { + return baseURL?.startsWith?.('./') ? baseURL.slice(1) : baseURL +} + function getAddressURL(addr: { address: string, port: number }, https: boolean) { const proto = https ? 'https' : 'http' let host = addr.address.includes(':') ? `[${addr.address}]` : addr.address diff --git a/packages/nuxt-cli/src/utils/dev-server.ts b/packages/nuxt-cli/src/utils/dev-server.ts index 0ba3eb364..f89e03098 100644 --- a/packages/nuxt-cli/src/utils/dev-server.ts +++ b/packages/nuxt-cli/src/utils/dev-server.ts @@ -1,3 +1,5 @@ +import type { LockInfo } from './lockfile' + import { existsSync, readFileSync } from 'node:fs' import { isIP } from 'node:net' import { networkInterfaces } from 'node:os' @@ -58,6 +60,16 @@ export async function findDevServer(cwd: string, buildDir?: string): Promise 0 && value <= 2 ** 31 - 1 @@ -162,6 +166,11 @@ function lockURL(value: unknown): string | undefined { } } +function lockURLPath(value: unknown): string | undefined { + const text = lockText(value) + return text && LOCK_URL_PATH_RE.test(text) ? text : undefined +} + /** * Validate a parsed `nuxt.lock` document. * @@ -189,6 +198,7 @@ export function parseLockInfo(raw: unknown): LockInfo | undefined { const port = input.port const hostname = lockText(input.hostname) + const baseURL = lockURLPath(input.baseURL) const parentPid = lockPid(input.parentPid) const serverPid = lockPid(input.serverPid) const takenOverBy = lockPid(input.takenOverBy) @@ -202,6 +212,7 @@ export function parseLockInfo(raw: unknown): LockInfo | undefined { ...typeof port === 'number' && Number.isInteger(port) && port > 0 && port <= 65_535 ? { port } : {}, ...hostname && LOCK_HOSTNAME_RE.test(hostname) ? { hostname } : {}, ...lockURL(input.url) ? { url: lockURL(input.url) } : {}, + ...baseURL ? { baseURL } : {}, ...parentPid ? { parentPid } : {}, ...serverPid ? { serverPid } : {}, ...takenOverBy ? { takenOverBy } : {}, diff --git a/packages/nuxt-cli/test/unit/listen.spec.ts b/packages/nuxt-cli/test/unit/listen.spec.ts index b68081b23..60bd9be63 100644 --- a/packages/nuxt-cli/test/unit/listen.spec.ts +++ b/packages/nuxt-cli/test/unit/listen.spec.ts @@ -5,7 +5,7 @@ import { networkInterfaces } from 'node:os' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { copyURL, formatDisplayURL, getNetworkAddresses, isReusePortSupported, listen, matchesBoundTarget, openBrowser, parsePort, resolveOpenCommand, validateHostname, writeClipboard } from '../../src/dev/listen' +import { canOpenBrowser, copyURL, formatDisplayURL, getNetworkAddresses, isReusePortSupported, listen, matchesBoundTarget, openBrowser, parsePort, resolveOpenCommand, validateHostname, writeClipboard } from '../../src/dev/listen' const writeText = vi.hoisted(() => vi.fn()) const isolatedEnvironment = vi.hoisted(() => ({ current: undefined as string | undefined })) @@ -495,6 +495,23 @@ describe('copyURL', () => { }) }) +describe('canOpenBrowser', () => { + afterEach(restoreEnvironment) + + it('should be false only when `openBrowser` has nothing to launch', () => { + stubEnvironment('linux') + expect(canOpenBrowser()).toBe(false) + stubEnvironment('linux', { DISPLAY: ':0' }) + expect(canOpenBrowser()).toBe(true) + stubEnvironment('linux', { BROWSER: '/home/user/.vscode-server/bin/helpers/browser.sh' }) + expect(canOpenBrowser()).toBe(true) + stubEnvironment('darwin', { BROWSER: 'none' }) + expect(canOpenBrowser()).toBe(false) + stubEnvironment('darwin') + expect(canOpenBrowser()).toBe(true) + }) +}) + describe('openBrowser', () => { afterEach(() => { restoreEnvironment() diff --git a/packages/nuxt-cli/test/unit/takeover.spec.ts b/packages/nuxt-cli/test/unit/takeover.spec.ts index 7c924f85b..76cc168f6 100644 --- a/packages/nuxt-cli/test/unit/takeover.spec.ts +++ b/packages/nuxt-cli/test/unit/takeover.spec.ts @@ -12,6 +12,17 @@ const checkPort = vi.hoisted(() => vi.fn<(port: number, host?: string) => Promis vi.mock('get-port-please', () => ({ checkPort })) +const { canOpenBrowser, openBrowser } = vi.hoisted(() => ({ + canOpenBrowser: vi.fn(() => true), + openBrowser: vi.fn(async (_url: string) => {}), +})) + +vi.mock('../../src/dev/listen', async importOriginal => ({ + ...await importOriginal(), + canOpenBrowser, + openBrowser, +})) + const { formatTakeoverRefusal, takeOverServer } = await import('../../src/dev/takeover') const { logger } = await import('../../src/utils/logger') const { getTakeoverPid, markTakenOver, readLock, updateLock } = await import('../../src/utils/lockfile') @@ -68,6 +79,8 @@ describe('takeOverServer', () => { delete process.env.NUXT_LOCK // Port in use by default: an active dev server holds it. checkPort.mockResolvedValue(false) + canOpenBrowser.mockReturnValue(true) + openBrowser.mockClear() }) afterEach(async () => { @@ -174,7 +187,7 @@ describe('takeOverServer', () => { mockProcess() const prompt = vi.fn(async (_lock: LockInfo, fallback: TakeoverChoice) => fallback) const result = await takeOverServer(buildDir, { interactive: true, prompt }) - expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ pid: HOLDER_PID }), 'takeover') + expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ pid: HOLDER_PID }), 'takeover', undefined) expect(result).toEqual({ action: 'taken', port: 3000, pid: HOLDER_PID }) }) @@ -191,7 +204,7 @@ describe('takeOverServer', () => { const proc = mockProcess() const prompt = vi.fn(async (_lock: LockInfo, fallback: TakeoverChoice) => fallback) const result = await takeOverServer(buildDir, { interactive: true, prompt }) - expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ pid: HOLDER_PID }), 'abort') + expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ pid: HOLDER_PID }), 'abort', undefined) expect(result).toMatchObject({ action: 'refused', reason: 'declined' }) expect(proc.signals).toHaveLength(0) }) @@ -228,6 +241,76 @@ describe('takeOverServer', () => { }) }) + describe('opening the running server', () => { + it('opens it on loopback under its base url, without stopping it', async () => { + writeLock(buildDir, { interactive: true, hostname: '0.0.0.0', url: 'http://0.0.0.0:3000', baseURL: '/admin/' }) + const proc = mockProcess() + const prompt = vi.fn(async () => 'open' as const) + expect(await takeOverServer(buildDir, { interactive: true, open: true, prompt })).toMatchObject({ action: 'opened' }) + expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:3000/admin/') + expect(prompt).toHaveBeenCalledWith(expect.anything(), 'open', 'http://127.0.0.1:3000/admin/') + expect(proc.signals).toHaveLength(0) + expect(readLock(buildDir)?.pid).toBe(HOLDER_PID) + }) + + it('opens only the origin and a plain base url from the lock', async () => { + writeLock(buildDir, { interactive: true, url: 'http://127.0.0.1:3000/x|calc', baseURL: '/"&calc' }) + mockProcess() + await takeOverServer(buildDir, { interactive: true, open: true, prompt: async () => 'open' }) + expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:3000/') + }) + + it('starts normally when the holder exits while the prompt is open', async () => { + writeLock(buildDir, { interactive: true }) + const proc = mockProcess() + const prompt = vi.fn(async () => { + checkPort.mockResolvedValue(3000) + return 'open' as const + }) + expect(await takeOverServer(buildDir, { interactive: true, open: true, prompt })).toEqual({ action: 'stale' }) + expect(openBrowser).not.toHaveBeenCalled() + expect(proc.signals).toHaveLength(0) + expect(readLock(buildDir)).toBeUndefined() + }) + + it('is the default for a holder started in a terminal, resolving `--open.url` against it', async () => { + writeLock(buildDir, { interactive: true, baseURL: '/admin/' }) + const proc = mockProcess() + const prompt = vi.fn(async (_lock: LockInfo, fallback: TakeoverChoice) => fallback) + const result = await takeOverServer(buildDir, { interactive: true, open: true, openURL: 'settings', prompt }) + expect(result).toMatchObject({ action: 'opened' }) + expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:3000/admin/settings') + expect(proc.signals).toHaveLength(0) + }) + + it('leaves taking over the default for a holder started without a terminal', async () => { + writeLock(buildDir, { interactive: false }) + mockProcess() + const prompt = vi.fn(async (_lock: LockInfo, fallback: TakeoverChoice) => fallback) + const result = await takeOverServer(buildDir, { interactive: true, open: true, prompt }) + expect(result).toEqual({ action: 'taken', port: 3000, pid: HOLDER_PID }) + expect(openBrowser).not.toHaveBeenCalled() + expect(prompt).toHaveBeenCalledWith(expect.anything(), 'takeover', 'http://127.0.0.1:3000/') + }) + + it.each([ + ['`--open` was not passed', {}, true, false], + ['the server has no url yet', { url: undefined }, true, true], + ['the url names another machine', { url: 'http://203.0.113.7:3000' }, true, true], + ['there is no browser to open', {}, false, true], + ])('is not offered when %s', async (_, lock: Partial, browser, open) => { + writeLock(buildDir, { interactive: true, ...lock }) + canOpenBrowser.mockReturnValue(browser) + const proc = mockProcess() + const prompt = vi.fn(async () => 'open' as const) + const result = await takeOverServer(buildDir, { interactive: true, open, prompt }) + expect(result).toMatchObject({ action: 'refused', reason: 'declined' }) + expect(openBrowser).not.toHaveBeenCalled() + expect(proc.signals).toHaveLength(0) + expect(prompt).toHaveBeenCalledWith(expect.anything(), 'abort', undefined) + }) + }) + describe('performing the takeover', () => { it('marks the lock so the outgoing process can explain itself', async () => { writeLock(buildDir) diff --git a/packages/nuxt-cli/test/unit/terminal-output.spec.ts b/packages/nuxt-cli/test/unit/terminal-output.spec.ts index 091e087b0..1169010d0 100644 --- a/packages/nuxt-cli/test/unit/terminal-output.spec.ts +++ b/packages/nuxt-cli/test/unit/terminal-output.spec.ts @@ -216,10 +216,7 @@ describe('dev server terminal output', () => { it('should say so when the browser launcher fails', async () => { const restore = stubDisplay({ browser: '/definitely/not/a/launcher' }) - const renderer = await render(async ({ waitForOutput }) => { - openBrowser('http://localhost:3000/') - await waitForOutput('Could not open') - }) + const renderer = await render(() => openBrowser('http://localhost:3000/')) restore() expect(screen(renderer)).toContain('Could not open http://localhost:/ in a browser.') @@ -229,10 +226,7 @@ describe('dev server terminal output', () => { // A launcher that fails, without depending on a platform-specific binary. const restore = stubDisplay({ browser: process.execPath, browserArgs: '-e process.exit(1)' }) - const renderer = await render(async ({ waitForOutput }) => { - openBrowser('http://localhost:3000/') - await waitForOutput('Could not open') - }) + const renderer = await render(() => openBrowser('http://localhost:3000/')) restore() expect(screen(renderer)).toContain('Could not open http://localhost:/ in a browser.') diff --git a/packages/nuxt-cli/test/unit/utils/untrusted-lock.spec.ts b/packages/nuxt-cli/test/unit/utils/untrusted-lock.spec.ts index 1217757e0..7b94c3827 100644 --- a/packages/nuxt-cli/test/unit/utils/untrusted-lock.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/untrusted-lock.spec.ts @@ -78,6 +78,15 @@ describe('parseLockInfo', () => { expect(parseLockInfo(baseLock({ url: 'http://localhost:3000' }))?.url).toBe('http://localhost:3000') }) + it('should drop a base url that is not a path', () => { + expect(parseLockInfo(baseLock({ baseURL: 'https://attacker.example/' }))?.baseURL).toBeUndefined() + expect(parseLockInfo(baseLock({ baseURL: '//attacker.example/' }))?.baseURL).toBeUndefined() + expect(parseLockInfo(baseLock({ baseURL: '/x|calc&y/' }))?.baseURL).toBeUndefined() + expect(parseLockInfo(baseLock({ baseURL: '/%PATH%/' }))?.baseURL).toBeUndefined() + expect(parseLockInfo(baseLock({ baseURL: '/admin/' }))?.baseURL).toBe('/admin/') + expect(parseLockInfo(baseLock({ baseURL: '/docs/v2' }))?.baseURL).toBe('/docs/v2') + }) + it('should strip control characters from displayed strings', () => { const info = parseLockInfo(baseLock({ cwd: '/tmp/\u001B[2Jproject\u0007' })) expect(info?.cwd).toBe('/tmp/[2Jproject')