Skip to content
Closed
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
27 changes: 27 additions & 0 deletions packages/cli-kit/src/private/node/ui/components/Alert.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,33 @@ describe('Alert', async () => {
`)
})

test('auto-detects URLs in a plain-string body and renders them as footnotes', async () => {
const options = {
body: 'See specification requirements: https://shopify.dev/docs/apps/build/sales-channels/channel-config-extension#specification-properties',
}

const {lastFrame} = render(<Alert type="error" {...options} />)
const frame = unstyled(lastFrame()!)

expect(frame).toMatchInlineSnapshot(`
"╭─ error ──────────────────────────────────────────────────────────────────────╮
│ │
│ See specification requirements: [1] │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
[1]
https://shopify.dev/docs/apps/build/sales-channels/channel-config-extension#specification-properties
"
`)

const footnoteLines = frame.split('\n').filter((line) => line.includes('[1]'))
footnoteLines.forEach((line) => {
if (line.startsWith('[1]')) {
expect(line).not.toContain('│')
}
})
})

test('has the headline in bold', async () => {
const options = {
headline: 'Title.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,43 @@ describe('FatalError', async () => {
`)
})

test('auto-detects URLs in a plain-string error message and renders them as footnotes', async () => {
const error = new AbortError(
'See specification requirements: https://shopify.dev/docs/apps/build/sales-channels/channel-config-extension#specification-properties',
)

const {lastFrame} = render(<FatalError error={error} />)
const frame = unstyled(lastFrame()!)

expect(frame).toMatchInlineSnapshot(`
"╭─ error ──────────────────────────────────────────────────────────────────────╮
│ │
│ See specification requirements: [1] │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
[1]
https://shopify.dev/docs/apps/build/sales-channels/channel-config-extension#specification-properties
"
`)
})

test('does not linkify user-supplied invalid URLs or placeholder URLs in error/tryMessage', async () => {
const error = new AbortError(`Invalid tunnel URL: https://asda`, `Valid format: "https://my-tunnel-url:port"`)

const {lastFrame} = render(<FatalError error={error} />)

expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
"╭─ error ──────────────────────────────────────────────────────────────────────╮
│ │
│ Invalid tunnel URL: https://asda │
│ │
│ Valid format: "https://my-tunnel-url:port" │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
"
`)
})

test('renders correctly with a formatted message', async () => {
const error = new AbortError([
'There has been an error creating your deployment:',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ const FatalError: FunctionComponent<FatalErrorProps> = ({error}) => {
</Text>
) : null}

{error.formattedMessage ? <TokenizedText item={error.formattedMessage} /> : <Text>{error.message}</Text>}
{error.formattedMessage ? (
<TokenizedText item={error.formattedMessage} />
) : (
<TokenizedText item={error.message} />
)}

{error.tryMessage ? <TokenizedText item={error.tryMessage} /> : null}

Expand Down
77 changes: 76 additions & 1 deletion packages/cli-kit/src/private/node/ui/components/Link.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
import {Link} from './Link.js'
import {LinksContext, Link as LinkEntry} from '../contexts/LinksContext.js'
import {render} from '../../testing/ui.js'
import {describe, expect, test, vi} from 'vitest'
import React from 'react'
import React, {FunctionComponent, useRef} from 'react'
import supportsHyperlinks from 'supports-hyperlinks'

vi.mock('supports-hyperlinks')

const WithLinksContext: FunctionComponent<{
children: React.ReactNode
addLinkSpy?: (label: string | undefined, url: string) => void
}> = ({children, addLinkSpy}) => {
const links = useRef<Record<string, LinkEntry>>({})
return (
<LinksContext.Provider
value={{
links,
addLink: (label, url) => {
addLinkSpy?.(label, url)
const newId = (Object.keys(links.current).length + 1).toString()
links.current = {...links.current, [newId]: {label, url}}
return newId
},
}}
>
{children}
</LinksContext.Provider>
)
}

describe('Link', async () => {
test("renders correctly with a fallback for terminals that don't support hyperlinks", async () => {
// Given
Expand Down Expand Up @@ -100,6 +123,58 @@ describe('Link', async () => {
expect(lastFrame()).toMatchInlineSnapshot('"https://example.com"')
})

describe('inside a LinksContext (Banner)', async () => {
test('renders just `[N]` (not `url [N]`) for label-less links when the terminal does not support hyperlinks', async () => {
supportHyperLinks(false)

const {lastFrame} = render(
<WithLinksContext>
<Link url="https://example.com" />
</WithLinksContext>,
)

expect(lastFrame()).toBe('[1]')
})

test('uses the footnote mechanism for labeled links when the terminal does not support hyperlinks', async () => {
supportHyperLinks(false)

const {lastFrame} = render(
<WithLinksContext>
<Link url="https://example.com" label="Example" />
</WithLinksContext>,
)

expect(lastFrame()).toBe('Example [1]')
})

test('registers label-less links in context so the footnote mechanism is always used', async () => {
supportHyperLinks(false)
const addLinkSpy = vi.fn()

render(
<WithLinksContext addLinkSpy={addLinkSpy}>
<Link url="https://example.com" />
</WithLinksContext>,
)

expect(addLinkSpy).toHaveBeenCalledWith(undefined, 'https://example.com')
})

test('registers labeled links in context', async () => {
supportHyperLinks(false)
const addLinkSpy = vi.fn()

render(
<WithLinksContext addLinkSpy={addLinkSpy}>
<Link url="https://example.com" label="Example" />
</WithLinksContext>,
)

expect(addLinkSpy).toHaveBeenCalledWith('Example', 'https://example.com')
})
})

function supportHyperLinks(isSupported: boolean) {
vi.mocked(supportsHyperlinks).stdout = isSupported
}
Expand Down
9 changes: 4 additions & 5 deletions packages/cli-kit/src/private/node/ui/components/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,15 @@ interface LinkProps {

function link(label: string | undefined, url: string, linksContext: LinksContextValue | null) {
if (!supportsHyperlinks.stdout) {
if (url === (label ?? url)) {
return url
}

if (linksContext === null) {
Comment thread
ryancbahan marked this conversation as resolved.
if (url === (label ?? url)) {
return url
}
return label ? `${label} ${chalk.dim(`( ${url} )`)}` : url
}

const linkId = linksContext.addLink(label, url)
return `${label ?? url} [${linkId}]`
return label ? `${label} [${linkId}]` : `[${linkId}]`
}

return ansiEscapes.link(label ?? url, url)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,31 @@
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} from 'vitest'
import {describe, expect, test, vi} from 'vitest'
import supportsHyperlinks from 'supports-hyperlinks'

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

vi.mock('supports-hyperlinks')

const WithLinksContext: FunctionComponent<{children: React.ReactNode}> = ({children}) => {
const links = useRef<Record<string, Link>>({})
return (
<LinksContext.Provider
value={{
links,
addLink: (label, url) => {
const newId = (Object.keys(links.current).length + 1).toString()
links.current = {...links.current, [newId]: {label, url}}
return newId
},
}}
>
{children}
</LinksContext.Provider>
)
}

describe('TokenizedText', async () => {
test('renders arrays of items separated by spaces', async () => {
Expand Down Expand Up @@ -57,6 +79,142 @@ describe('TokenizedText', async () => {
`)
})

describe('URL auto-detection in plain strings', async () => {
test('renders strings without URLs unchanged', async () => {
vi.mocked(supportsHyperlinks).stdout = false

const {lastFrame} = render(
<WithLinksContext>
<TokenizedText item="no link here, just text" />
</WithLinksContext>,
)

expect(lastFrame()).toBe('no link here, just text')
})

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

const {lastFrame} = render(
<WithLinksContext>
<TokenizedText item={`See specification requirements: ${url}`} />
</WithLinksContext>,
)

expect(lastFrame()).toBe('See specification requirements: [1]')
expect(lastFrame()).not.toContain(url)
})

test('wraps detected URLs in OSC 8 escapes when the terminal supports hyperlinks', async () => {
vi.mocked(supportsHyperlinks).stdout = true
const url = 'https://example.com/docs'

const {lastFrame} = render(
<WithLinksContext>
<TokenizedText item={`visit ${url} now`} />
</WithLinksContext>,
)

expect(lastFrame()).toContain(`]8;;${url}${url}]8;;`)
})

test('detects multiple URLs in the same string', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth adding edge test cases where there's back to back URLs

vi.mocked(supportsHyperlinks).stdout = true
const first = 'https://example.com/a'
const second = 'https://example.com/b'

const {lastFrame} = render(
<WithLinksContext>
<TokenizedText item={`see ${first} and ${second}`} />
</WithLinksContext>,
)

expect(lastFrame()).toContain(`]8;;${first}${first}]8;;`)
expect(lastFrame()).toContain(`]8;;${second}${second}]8;;`)
})

test('detects back-to-back URLs separated only by whitespace', async () => {
vi.mocked(supportsHyperlinks).stdout = true
const first = 'https://example.com/a'
const second = 'https://example.com/b'

const {lastFrame} = render(
<WithLinksContext>
<TokenizedText item={`${first} ${second}`} />
</WithLinksContext>,
)

expect(lastFrame()).toContain(`]8;;${first}${first}]8;;`)
expect(lastFrame()).toContain(`]8;;${second}${second}]8;;`)
})

test('preserves balanced parentheses inside URLs (e.g. Wikipedia disambiguation links)', async () => {
vi.mocked(supportsHyperlinks).stdout = true
const url = 'https://en.wikipedia.org/wiki/Ruby_(programming_language)'

const {lastFrame} = render(
<WithLinksContext>
<TokenizedText item={`see ${url} for details`} />
</WithLinksContext>,
)

expect(lastFrame()).toContain(`]8;;${url}${url}]8;;`)
})

test('does not auto-linkify URLs outside a LinksContext', async () => {
vi.mocked(supportsHyperlinks).stdout = true
const url = 'https://example.com/docs'

const {lastFrame} = render(<TokenizedText item={`visit ${url} now`} />)

expect(lastFrame()).toBe(`visit ${url} now`)
expect(lastFrame()).not.toContain(']8;;')
})

test('does not linkify https://-prefixed strings whose hostname has no dot (e.g. user typos like https://asda)', async () => {
vi.mocked(supportsHyperlinks).stdout = true
const candidate = 'https://asda'

const {lastFrame} = render(
<WithLinksContext>
<TokenizedText item={`Invalid tunnel URL: ${candidate}`} />
</WithLinksContext>,
)

expect(lastFrame()).toBe(`Invalid tunnel URL: ${candidate}`)
expect(lastFrame()).not.toContain(']8;;')
})

test('does not linkify placeholder URLs with non-numeric ports (e.g. https://my-tunnel-url:port)', async () => {
vi.mocked(supportsHyperlinks).stdout = true
const candidate = 'https://my-tunnel-url:port'

const {lastFrame} = render(
<WithLinksContext>
<TokenizedText item={`Valid format: "${candidate}"`} />
</WithLinksContext>,
)

expect(lastFrame()).toBe(`Valid format: "${candidate}"`)
expect(lastFrame()).not.toContain(']8;;')
})

test('strips trailing sentence punctuation from detected URLs', async () => {
vi.mocked(supportsHyperlinks).stdout = true
const url = 'https://example.com/docs'

const {lastFrame} = render(
<WithLinksContext>
<TokenizedText item={`see ${url}. Thanks`} />
</WithLinksContext>,
)

expect(lastFrame()).toContain(`]8;;${url}${url}]8;;`)
expect(lastFrame()).toContain('. Thanks')
})
})

describe('tokenItemToString', async () => {
test("doesn't add a space before char", async () => {
expect(tokenItemToString(['Run', {char: '!'}])).toBe('Run!')
Expand Down
Loading
Loading