Skip to content
Open
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
14 changes: 12 additions & 2 deletions src/__tests__/Home.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,17 @@ describe('Home', () => {
expect(image).toMatchImageSnapshot()
})

it('9. should scroll to the first file in diff', async () => {
it('9. should show the AI prompt copy button after diff loads', async () => {
await page.waitForSelector(
`[data-testid="${upgradeButtonTestIDs.aiPromptButton}"]`
)

const image = await page.screenshot()

expect(image).toMatchImageSnapshot()
})

it('10. should scroll to the first file in diff', async () => {
await page.evaluate((testID) => {
document
.querySelector(`[data-testid="${testID}"]`)
Expand All @@ -125,7 +135,7 @@ describe('Home', () => {
expect(image).toMatchImageSnapshot()
})

it('10. should collapse first file in diff', async () => {
it('11. should collapse first file in diff', async () => {
await page.click(
`[data-testid="${diffHeaderTestIDs.collapseClickableArea}"]`
)
Expand Down
30 changes: 30 additions & 0 deletions src/__tests__/components/common/UpgradeButton.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react'
import { fireEvent, render, waitFor } from '@testing-library/react'
import UpgradeButton, {
testIDs,
} from '../../../components/common/UpgradeButton'

describe('UpgradeButton', () => {
it('does not render the AI prompt button until the prompt is ready', () => {
const { queryByTestId } = render(<UpgradeButton onShowDiff={jest.fn()} />)

expect(queryByTestId(testIDs.aiPromptButton)).toBeNull()
})

it('shows the AI prompt button and triggers copy when clicked', async () => {
const onAiPromptClick = jest.fn().mockResolvedValue(undefined)
const { getByTestId } = render(
<UpgradeButton
onShowDiff={jest.fn()}
showAiPromptButton={true}
onAiPromptClick={onAiPromptClick}
/>
)

fireEvent.click(getByTestId(testIDs.aiPromptButton))

await waitFor(() => {
expect(onAiPromptClick).toHaveBeenCalledTimes(1)
})
})
})
116 changes: 116 additions & 0 deletions src/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { PACKAGE_NAMES } from '../constants'
import { parseDiff } from 'react-diff-view'
import '../releases/__mocks__/index'
import {
getVersionsContentInDiff,
replaceAppDetails,
getChangelogURL,
buildAiUpgradePrompt,
} from '../utils'

describe('getVersionsContentInDiff', () => {
Expand Down Expand Up @@ -132,3 +134,117 @@ describe('replaceAppDetails ', () => {
}
)
})

describe('buildAiUpgradePrompt', () => {
const createFiles = (rawDiffText: string) => parseDiff(rawDiffText)
const createPrompt = (
overrides: Partial<Parameters<typeof buildAiUpgradePrompt>[0]>
) =>
buildAiUpgradePrompt({
files: [],
packageName: PACKAGE_NAMES.RN,
language: 'cpp',
fromVersion: '0.63.2',
toVersion: '0.64.2',
...overrides,
})

it('includes overview, warnings, and structured file changes', () => {
const prompt = createPrompt({
files: createFiles(
[
'diff --git a/RnDiffApp/App.js b/RnDiffApp/App.js',
'--- a/RnDiffApp/App.js',
'+++ b/RnDiffApp/App.js',
'@@ -1 +1 @@',
'-package com.rndiffapp;',
'+package com.rndiffapp;',
].join('\n')
),
appName: 'MyApp',
appPackage: 'com.example.myapp',
})

expect(prompt).toContain(
'You are helping upgrade a React Native app using the provided template diff.'
)
expect(prompt).toContain('# React Native upgrade guidance request')
expect(prompt).toContain('## Task overview')
expect(prompt).toContain('- From version: 0.63.2')
expect(prompt).toContain('- To version: 0.64.2')
expect(prompt).toContain('- App name input or fallback: MyApp')
expect(prompt).toContain(
'- App package input or fallback: com.example.myapp'
)
expect(prompt).toContain(
'The app name and app package values may be inaccurate because the user may have left them unchanged, blank, or approximate. Verify them against the real project before applying changes.'
)
expect(prompt).toContain(
'This diff only represents the React Native bootstrap/template project between versions. First understand the current project structure and apply only the changes that are relevant to this codebase.'
)
expect(prompt).toContain('\n\n## Task overview')
expect(prompt).toContain('## File changes')
expect(prompt).toContain('\n\n### `App.js`')
expect(prompt).toContain('### `App.js`')
expect(prompt).toContain('- Change type: Modified')
expect(prompt).toContain('```diff')
expect(prompt).toContain('```')
expect(prompt).toContain('-package com.example.myapp;')
})

it('omits binary patch contents and adds download guidance', () => {
const prompt = createPrompt({
files: createFiles(
[
'diff --git a/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar b/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar',
'index abc..def 100644',
'Binary files a/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar and b/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar differ',
'diff --git a/RnDiffApp/App.js b/RnDiffApp/App.js',
'--- a/RnDiffApp/App.js',
'+++ b/RnDiffApp/App.js',
'@@ -1 +1 @@',
'-console.log("old")',
'+console.log("new")',
].join('\n')
),
})

expect(prompt).toContain('## Binary file handling')
expect(prompt).toContain(
'\n\n### `android/gradle/wrapper/gradle-wrapper.jar`'
)
expect(prompt).toContain('### `android/gradle/wrapper/gradle-wrapper.jar`')
expect(prompt).toContain('```bash')
expect(prompt).toContain(
'curl -L "https://raw.githubusercontent.com/react-native-community/rn-diff-purge/release/0.64.2/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar" -o "android/gradle/wrapper/gradle-wrapper.jar"'
)
expect(prompt).not.toContain(
'Binary files a/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar'
)
expect(prompt).toContain('### `App.js`')
})

it('tells the agent to remove deleted binary files instead of downloading them', () => {
const prompt = createPrompt({
files: createFiles(
[
'diff --git a/RnDiffApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/RnDiffApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png',
'deleted file mode 100644',
'index abcdef..000000',
'Binary files a/RnDiffApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ',
].join('\n')
),
})

expect(prompt).toContain(
'\n\n### `android/app/src/main/res/mipmap-hdpi/ic_launcher.png`'
)
expect(prompt).toContain(
'### `android/app/src/main/res/mipmap-hdpi/ic_launcher.png`'
)
expect(prompt).toContain(
'- Remove this file from the target project if it still exists.'
)
expect(prompt).not.toContain('raw.githubusercontent.com')
})
})
8 changes: 8 additions & 0 deletions src/components/common/DiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ interface DiffViewerProps {
onToggleChangeSelection: (args: ChangeEventArgs) => void
appName: string
appPackage: string
onDiffResolved?: (diff: File[]) => void
}
const DiffViewer = ({
packageName,
Expand All @@ -83,6 +84,7 @@ const DiffViewer = ({
onToggleChangeSelection,
appName,
appPackage,
onDiffResolved,
}: DiffViewerProps) => {
const { isLoading, isDone, diff } = useFetchDiff({
shouldShowDiff,
Expand Down Expand Up @@ -178,6 +180,12 @@ const DiffViewer = ({
}
}, [isDone])

useEffect(() => {
if (isDone) {
onDiffResolved?.(diff)
}
}, [isDone, diff, onDiffResolved])

if (!shouldShowDiff) {
return null
}
Expand Down
69 changes: 55 additions & 14 deletions src/components/common/UpgradeButton.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,84 @@
import React from 'react'
import styled from '@emotion/styled'
import { Button as AntdButton, ButtonProps } from 'antd'
import { CopyOutlined } from '@ant-design/icons'
import { deviceSizes } from '../../utils/device-sizes'

export const testIDs = {
upgradeButton: 'upgradeButton',
aiPromptButton: 'aiPromptButton',
}

const Container = styled.div`
display: flex;
align-items: center;
gap: 12px;
justify-content: center;
height: auto;
overflow: hidden;
margin-top: 28px;

@media ${deviceSizes.mobile} {
flex-direction: column;
}
`

const Button = styled(AntdButton)`
border-radius: 5px;
`

const AiPromptButton = styled(Button)`
&& {
background: linear-gradient(135deg, #ff6fb5 0%, #59c4ff 100%) !important;
border: 0;
box-shadow: 0 2px 0 rgba(89, 196, 255, 0.24);
color: #fff;
}

&&:hover {
opacity: 0.8;
}
`

interface UpgradeButtonProps extends React.PropsWithRef<ButtonProps> {
onShowDiff: () => void
showAiPromptButton?: boolean
onAiPromptClick?: () => Promise<void>
}

const UpgradeButton = React.forwardRef<
HTMLElement,
UpgradeButtonProps & React.RefAttributes<HTMLElement>
>(({ onShowDiff, ...props }, ref) => (
<Container>
<Button
{...props}
ref={ref}
type="primary"
size="large"
data-testid={testIDs.upgradeButton}
onClick={onShowDiff}
>
Show me how to upgrade!
</Button>
</Container>
))
>(
(
{ onShowDiff, showAiPromptButton = false, onAiPromptClick, ...props },
ref
) => (
<Container>
<Button
{...props}
ref={ref}
type="primary"
size="large"
data-testid={testIDs.upgradeButton}
onClick={onShowDiff}
>
Show me how to upgrade!
</Button>

{showAiPromptButton && (
<AiPromptButton
type="primary"
size="large"
data-testid={testIDs.aiPromptButton}
onClick={onAiPromptClick}
>
Copy for AI
<CopyOutlined />
</AiPromptButton>
)}
</Container>
)
)

export default UpgradeButton
19 changes: 18 additions & 1 deletion src/components/common/VersionSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ const VersionSelector = ({
showReleaseCandidates,
appPackage,
appName,
isAiPromptReady,
onCopyAiPrompt,
resolvedFromVersion,
resolvedToVersion,
}: {
packageName: string
language: string
Expand All @@ -233,6 +237,10 @@ const VersionSelector = ({
showReleaseCandidates: boolean
appPackage: string
appName?: string
isAiPromptReady: boolean
onCopyAiPrompt: () => Promise<void>
resolvedFromVersion: string
resolvedToVersion: string
}) => {
const { isLoading, isDone, releaseVersions } = useFetchReleaseVersions({
packageName,
Expand Down Expand Up @@ -365,6 +373,10 @@ const VersionSelector = ({
appName,
})
}
const showAiPromptButton =
isAiPromptReady &&
localFromVersion === resolvedFromVersion &&
localToVersion === resolvedToVersion

return (
<Fragment>
Expand Down Expand Up @@ -403,7 +415,12 @@ const VersionSelector = ({
/>
</Selectors>

<UpgradeButton ref={upgradeButtonEl} onShowDiff={onShowDiff} />
<UpgradeButton
ref={upgradeButtonEl}
onShowDiff={onShowDiff}
showAiPromptButton={showAiPromptButton}
onAiPromptClick={onCopyAiPrompt}
/>
</Fragment>
)
}
Expand Down
Loading