Skip to content
Draft
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
4 changes: 2 additions & 2 deletions packages/cli-kit/src/private/node/content-tokens.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import colors from '../../public/node/colors.js'
import {OutputMessage, stringifyMessage} from '../../public/node/output.js'
import {relativizePath} from '../../public/node/path.js'
import {terminalSupportsHyperlinks} from '../../public/node/system.js'
import ansiEscapes from 'ansi-escapes'
import supportsHyperlinks from 'supports-hyperlinks'
import cjs from 'color-json'
import type {Change} from 'diff'

Expand Down Expand Up @@ -36,7 +36,7 @@ export class LinkContentToken extends ContentToken<OutputMessage> {
const text = colors.green(stringifyMessage(this.value))
const url = this.link ?? ''
const defaultFallback = this.value === this.link ? text : `${text} ( ${url} )`
if (supportsHyperlinks.stdout) {
if (terminalSupportsHyperlinks()) {
return ansiEscapes.link(text, url)
}
return this.fallback ?? defaultFallback
Expand Down
18 changes: 14 additions & 4 deletions packages/cli-kit/src/private/node/ui/components/Link.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import {Link} from './Link.js'
import {LinksContext} from '../contexts/LinksContext.js'
import {render} from '../../testing/ui.js'
import {describe, expect, test, vi} from 'vitest'
import {terminalSupportsHyperlinks, _resetTerminalSupportsHyperlinksCache} from '../../../../public/node/system.js'
import {describe, expect, test, vi, beforeEach} from 'vitest'
import React from 'react'
import supportsHyperlinks from 'supports-hyperlinks'

vi.mock('supports-hyperlinks')
vi.mock('../../../../public/node/system.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../../public/node/system.js')>()
return {
...actual,
terminalSupportsHyperlinks: vi.fn(),
}
})

describe('Link', async () => {
beforeEach(() => {
_resetTerminalSupportsHyperlinksCache()
})

test("renders correctly with a fallback for terminals that don't support hyperlinks", async () => {
// Given
supportHyperLinks(false)
Expand Down Expand Up @@ -164,7 +174,7 @@ describe('Link', async () => {
})

function supportHyperLinks(isSupported: boolean) {
vi.mocked(supportsHyperlinks).stdout = isSupported
vi.mocked(terminalSupportsHyperlinks).mockReturnValue(isSupported)
}

function asLink(url: string, label?: string) {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-kit/src/private/node/ui/components/Link.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {terminalSupportsHyperlinks} from '../../../../public/node/system.js'
import {LinksContext, ContextValue as LinksContextValue} from '../contexts/LinksContext.js'
import {Text} from 'ink'
import React, {FunctionComponent, useContext} from 'react'
import ansiEscapes from 'ansi-escapes'
import supportsHyperlinks from 'supports-hyperlinks'
import chalk from 'chalk'

interface LinkProps {
Expand All @@ -11,7 +11,7 @@ interface LinkProps {
}

function link(label: string | undefined, url: string, linksContext: LinksContextValue | null) {
if (!supportsHyperlinks.stdout) {
if (!terminalSupportsHyperlinks()) {
if (linksContext === null) {
if (url === (label ?? url)) {
return url
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ import {tokenItemToString, TokenizedText} from './TokenizedText.js'
import {LinksContext, Link} from '../contexts/LinksContext.js'
import {unstyled} from '../../../../public/node/output.js'
import {render} from '../../testing/ui.js'
import {describe, expect, test, vi} from 'vitest'
import supportsHyperlinks from 'supports-hyperlinks'
import {terminalSupportsHyperlinks, _resetTerminalSupportsHyperlinksCache} from '../../../../public/node/system.js'
import {describe, expect, test, vi, beforeEach} from 'vitest'

import React, {FunctionComponent, useRef} from 'react'

vi.mock('supports-hyperlinks')
vi.mock('../../../../public/node/system.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../../public/node/system.js')>()
return {
...actual,
terminalSupportsHyperlinks: vi.fn(),
}
})

// Matches the on-the-wire OSC 8 sequence emitted by `ansiEscapes.link`,
// which is what `<Link>` ultimately renders when the terminal supports
Expand Down Expand Up @@ -37,6 +43,10 @@ const WithLinksContext: FunctionComponent<{children: React.ReactNode}> = ({child
}

describe('TokenizedText', async () => {
beforeEach(() => {
_resetTerminalSupportsHyperlinksCache()
})

test('renders arrays of items separated by spaces', async () => {
const item = [
'Run',
Expand Down Expand Up @@ -90,7 +100,7 @@ describe('TokenizedText', async () => {

describe('markdown-link parsing in plain strings', async () => {
test('renders strings without a markdown link unchanged', async () => {
vi.mocked(supportsHyperlinks).stdout = false
vi.mocked(terminalSupportsHyperlinks).mockReturnValue(false)

const {lastFrame} = render(
<WithLinksContext>
Expand All @@ -102,7 +112,7 @@ describe('TokenizedText', async () => {
})

test('does not linkify a bare URL — callers must opt in via `[label](url)` or `<url>`', async () => {
vi.mocked(supportsHyperlinks).stdout = true
vi.mocked(terminalSupportsHyperlinks).mockReturnValue(true)
const url = 'https://example.com/docs'

const {lastFrame} = render(
Expand All @@ -116,7 +126,7 @@ describe('TokenizedText', async () => {
})

test('replaces an opt-in `[label](url)` with the label and a `[N]` footnote anchor when the terminal does not support hyperlinks', async () => {
vi.mocked(supportsHyperlinks).stdout = false
vi.mocked(terminalSupportsHyperlinks).mockReturnValue(false)
const url = 'https://shopify.dev/docs/apps/build/sales-channels/channel-config-extension#specification-properties'

const {lastFrame} = render(
Expand All @@ -130,7 +140,7 @@ describe('TokenizedText', async () => {
})

test('wraps the label of a `[label](url)` in OSC 8 escapes when the terminal supports hyperlinks', async () => {
vi.mocked(supportsHyperlinks).stdout = true
vi.mocked(terminalSupportsHyperlinks).mockReturnValue(true)
const url = 'https://example.com/docs'

const {lastFrame} = render(
Expand All @@ -143,7 +153,7 @@ describe('TokenizedText', async () => {
})

test('renders a label-less `<url>` autolink as a `[N]` anchor and registers the URL in the footnote table', async () => {
vi.mocked(supportsHyperlinks).stdout = false
vi.mocked(terminalSupportsHyperlinks).mockReturnValue(false)
const url = 'https://shopify.dev/docs'

const {lastFrame} = render(
Expand All @@ -157,7 +167,7 @@ describe('TokenizedText', async () => {
})

test('parses multiple opt-in links in the same string', async () => {
vi.mocked(supportsHyperlinks).stdout = false
vi.mocked(terminalSupportsHyperlinks).mockReturnValue(false)
const first = 'https://example.com/a'
const second = 'https://example.com/b'

Expand All @@ -171,7 +181,7 @@ describe('TokenizedText', async () => {
})

test('parses back-to-back opt-in links separated only by whitespace', async () => {
vi.mocked(supportsHyperlinks).stdout = false
vi.mocked(terminalSupportsHyperlinks).mockReturnValue(false)
const first = 'https://example.com/a'
const second = 'https://example.com/b'

Expand All @@ -185,7 +195,7 @@ describe('TokenizedText', async () => {
})

test('does not parse markdown links that omit the http(s) scheme', async () => {
vi.mocked(supportsHyperlinks).stdout = true
vi.mocked(terminalSupportsHyperlinks).mockReturnValue(true)

const {lastFrame} = render(
<WithLinksContext>
Expand All @@ -198,7 +208,7 @@ describe('TokenizedText', async () => {
})

test('does not parse opt-in markdown when no LinksContext is present (e.g. outside a Banner)', async () => {
vi.mocked(supportsHyperlinks).stdout = true
vi.mocked(terminalSupportsHyperlinks).mockReturnValue(true)
const url = 'https://example.com/docs'

const {lastFrame} = render(<TokenizedText item={`see [docs](${url}) now`} />)
Expand All @@ -213,7 +223,7 @@ describe('TokenizedText', async () => {
// which is misleading. With opt-in markdown the bare URL stays
// as-is and only the doc reference — which the server marks up —
// becomes clickable.
vi.mocked(supportsHyperlinks).stdout = true
vi.mocked(terminalSupportsHyperlinks).mockReturnValue(true)
const tunnelUrl = 'https://wrong'
const docUrl = 'https://shopify.dev/docs/tunnels'

Expand Down
32 changes: 31 additions & 1 deletion packages/cli-kit/src/public/node/system.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import * as system from './system.js'
import {execa} from 'execa'
import supportsHyperlinks from 'supports-hyperlinks'
import {describe, expect, test, vi} from 'vitest'
import which from 'which'
import {Readable} from 'stream'

import * as fs from 'fs'

vi.mock('which')
Expand Down Expand Up @@ -353,6 +353,36 @@ describe('isStdinPiped', () => {
})
})

describe('terminalSupportsHyperlinks', () => {
test('memoizes the result and is reset by _resetTerminalSupportsHyperlinksCache', () => {
// Given
const stdoutSpy = vi.spyOn(supportsHyperlinks, 'stdout', 'get')

// When
system._resetTerminalSupportsHyperlinksCache()
stdoutSpy.mockReturnValue(true)
const firstCall = system.terminalSupportsHyperlinks()
const secondCall = system.terminalSupportsHyperlinks()

// Then
expect(firstCall).toBe(true)
expect(secondCall).toBe(true)
expect(stdoutSpy).toHaveBeenCalledTimes(1)

// When
system._resetTerminalSupportsHyperlinksCache()
stdoutSpy.mockReturnValue(false)
const thirdCall = system.terminalSupportsHyperlinks()

// Then
expect(thirdCall).toBe(false)
expect(stdoutSpy).toHaveBeenCalledTimes(2)

stdoutSpy.mockRestore()
vi.resetModules()
})
})

describe('readStdinString', () => {
test('returns undefined when stdin is not piped', async () => {
// Given
Expand Down
15 changes: 14 additions & 1 deletion packages/cli-kit/src/public/node/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,13 +324,26 @@ export async function sleep(seconds: number): Promise<void> {
})
}

/**
* Memoized value for the terminal hyperlinks support check.
*/
let terminalSupportsHyperlinksCache: boolean | undefined

/**
* Check if the terminal supports OSC 8 hyperlinks.
*
* @returns True if the terminal supports hyperlinks.
*/
export function terminalSupportsHyperlinks(): boolean {
return supportsHyperlinks.stdout
return (terminalSupportsHyperlinksCache ??= supportsHyperlinks.stdout)
}

/**
* Resets the terminal hyperlinks support cache.
* This is only intended for use in tests.
*/
export function _resetTerminalSupportsHyperlinksCache(): void {
terminalSupportsHyperlinksCache = undefined
}

/**
Expand Down
13 changes: 10 additions & 3 deletions packages/cli-kit/src/public/node/ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,22 @@ import {AbortSignal} from './abort.js'
import {BugError, FatalError, AbortError, FatalErrorType} from './error.js'
import {mockAndCaptureOutput} from './testing/output.js'
import {TokenizedString} from './output.js'
import {terminalSupportsHyperlinks, _resetTerminalSupportsHyperlinksCache} from './system.js'
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
import supportsHyperlinks from 'supports-hyperlinks'

import {Writable} from 'stream'

vi.mock('supports-hyperlinks')
vi.mock('./system.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./system.js')>()
return {
...actual,
terminalSupportsHyperlinks: vi.fn(),
}
})

beforeEach(() => {
vi.mocked(supportsHyperlinks).stdout = false
_resetTerminalSupportsHyperlinksCache()
vi.mocked(terminalSupportsHyperlinks).mockReturnValue(false)
})

afterEach(() => {
Expand Down
Loading