This repository has been archived by the owner on Jan 18, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 477
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feature(create-expo-app): add
--example
flag to initialize from exa…
…mple (#4644) * feature(create-expo-app): add support for examples * fix(create-expo-app): update to tar 6.1.13 to avoid premature closed stream errors with node 18+ * test(create-expo-app): explicitly turn off CI even in CI * fix(create-expo-app): throw when --template and --example are both used
- Loading branch information
Showing
11 changed files
with
551 additions
and
25 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
import JsonFile from '@expo/json-file'; | ||
import chalk from 'chalk'; | ||
import fs from 'fs'; | ||
import fetch from 'node-fetch'; | ||
import path from 'path'; | ||
import prompts from 'prompts'; | ||
import { Stream } from 'stream'; | ||
import tar from 'tar'; | ||
import { promisify } from 'util'; | ||
|
||
import { sanitizeTemplateAsync } from './Template'; | ||
import { createEntryResolver, createFileTransform } from './createFileTransform'; | ||
import { env } from './utils/env'; | ||
|
||
const debug = require('debug')('expo:init:template') as typeof console.log; | ||
const pipeline = promisify(Stream.pipeline); | ||
|
||
/** | ||
* The partial GitHub content type, used to filter out examples. | ||
* @see https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28 | ||
*/ | ||
export type GithubContent = { | ||
name: string; | ||
path: string; | ||
type: 'file' | 'dir'; | ||
}; | ||
|
||
/** List all existing examples directory from https://github.com/expo/examples. */ | ||
async function listExamplesAsync() { | ||
const response = await fetch('https://api.github.com/repos/expo/examples/contents'); | ||
if (!response.ok) { | ||
throw new Error('Unexpected GitHub API response: https://github.com/expo/examples'); | ||
} | ||
|
||
const data: GithubContent[] = await response.json(); | ||
return data.filter(item => item.type === 'dir' && !item.name.startsWith('.')); | ||
} | ||
|
||
/** Determine if an example exists, using only its name */ | ||
async function hasExampleAsync(name: string) { | ||
const response = await fetch( | ||
`https://api.github.com/repos/expo/examples/contents/${encodeURIComponent(name)}/package.json` | ||
); | ||
|
||
// Either ok or 404 responses are expected | ||
if (response.status === 404 || response.ok) { | ||
return response.ok; | ||
} | ||
|
||
throw new Error(`Unexpected GitHub API response: ${response.status} - ${response.statusText}`); | ||
} | ||
|
||
export async function ensureExampleExists(name: string) { | ||
if (!(await hasExampleAsync(name))) { | ||
throw new Error(`Example "${name}" does not exist, see https://github.com/expo/examples`); | ||
} | ||
} | ||
|
||
/** Ask the user which example to create */ | ||
export async function promptExamplesAsync() { | ||
if (env.CI) { | ||
throw new Error('Cannot prompt for examples in CI'); | ||
} | ||
|
||
const examples = await listExamplesAsync(); | ||
const { answer } = await prompts({ | ||
type: 'select', | ||
name: 'answer', | ||
message: 'Choose an example:', | ||
choices: examples.map(example => ({ | ||
title: example.name, | ||
value: example.path, | ||
})), | ||
}); | ||
|
||
if (!answer) { | ||
console.log(); | ||
console.log(chalk`Please specify the example, example: {cyan --example with-router}`); | ||
console.log(); | ||
process.exit(1); | ||
} | ||
|
||
return answer; | ||
} | ||
|
||
/** Download and move the selected example from https://github.com/expo/examples. */ | ||
export async function downloadAndExtractExampleAsync(root: string, name: string) { | ||
const projectName = path.basename(root); | ||
const response = await fetch('https://codeload.github.com/expo/examples/tar.gz/master'); | ||
if (!response.ok) { | ||
debug(`Failed to fetch the examples code, received status "${response.status}"`); | ||
throw new Error('Failed to fetch the examples code from https://github.com/expo/examples'); | ||
} | ||
|
||
await pipeline( | ||
response.body, | ||
tar.extract( | ||
{ | ||
cwd: root, | ||
transform: createFileTransform(projectName), | ||
onentry: createEntryResolver(projectName), | ||
strip: 2, | ||
}, | ||
[`examples-master/${name}`] | ||
) | ||
); | ||
|
||
await sanitizeTemplateAsync(root); | ||
await sanitizeScriptsAsync(root); | ||
} | ||
|
||
function exampleHasNativeCode(root: string): boolean { | ||
return [path.join(root, 'android'), path.join(root, 'ios')].some(folder => fs.existsSync(folder)); | ||
} | ||
|
||
export async function sanitizeScriptsAsync(root: string) { | ||
const defaultScripts = exampleHasNativeCode(root) | ||
? { | ||
start: 'expo start --dev-client', | ||
android: 'expo run:android', | ||
ios: 'expo run:ios', | ||
web: 'expo start --web', | ||
} | ||
: { | ||
start: 'expo start', | ||
android: 'expo start --android', | ||
ios: 'expo start --ios', | ||
web: 'expo start --web', | ||
}; | ||
|
||
const packageFile = new JsonFile(path.join(root, 'package.json')); | ||
const packageJson = await packageFile.readAsync(); | ||
|
||
const scripts = (packageJson.scripts ?? {}) as Record<string, string>; | ||
packageJson.scripts = { ...defaultScripts, ...scripts }; | ||
|
||
await packageFile.writeAsync(packageJson); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import { fs } from 'memfs'; | ||
|
||
module.exports = fs; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
const ora = jest.fn(() => { | ||
return { | ||
start: jest.fn(() => { | ||
return { stop: jest.fn(), succeed: jest.fn(), fail: jest.fn() }; | ||
}), | ||
stop: jest.fn(), | ||
stopAndPersist: jest.fn(), | ||
succeed: jest.fn(), | ||
fail: jest.fn(), | ||
}; | ||
}); | ||
|
||
module.exports = ora; |
135 changes: 135 additions & 0 deletions
135
packages/create-expo-app/src/__tests__/Examples.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
import { vol } from 'memfs'; | ||
import typedFetch from 'node-fetch'; | ||
import typedPrompts from 'prompts'; | ||
|
||
import { | ||
ensureExampleExists, | ||
GithubContent, | ||
promptExamplesAsync, | ||
sanitizeScriptsAsync, | ||
} from '../Examples'; | ||
import { env } from '../utils/env'; | ||
|
||
jest.mock('fs'); | ||
jest.mock('node-fetch'); | ||
jest.mock('prompts'); | ||
|
||
const fetch = typedFetch as jest.MockedFunction<typeof typedFetch>; | ||
const prompts = typedPrompts as jest.MockedFunction<typeof typedPrompts>; | ||
|
||
describe(ensureExampleExists, () => { | ||
it('resolves when example exists', async () => { | ||
fetch.mockResolvedValue({ ok: true, status: 200 } as any); | ||
await expect(ensureExampleExists('test')).resolves.not.toThrow(); | ||
}); | ||
|
||
it('rejects when example does note exists', async () => { | ||
fetch.mockResolvedValue({ ok: false, status: 404 } as any); | ||
await expect(() => ensureExampleExists('test')).rejects.toThrow(/example.*does not exist/i); | ||
}); | ||
|
||
it('throws when running into rate limits', async () => { | ||
fetch.mockResolvedValue({ ok: false, status: 403 } as any); | ||
await expect(() => ensureExampleExists('test')).rejects.toThrow( | ||
/unexpected GitHub API response/i | ||
); | ||
}); | ||
}); | ||
|
||
describe(promptExamplesAsync, () => { | ||
it('throws when in CI mode', async () => { | ||
const spy = jest.spyOn(env, 'CI', 'get').mockReturnValue(true); | ||
await expect(() => promptExamplesAsync()).rejects.toThrowError(/cannot prompt/i); | ||
spy.mockRestore(); | ||
}); | ||
|
||
it('prompts examples and return selected example', async () => { | ||
// Make this test run in CI | ||
const spy = jest.spyOn(env, 'CI', 'get').mockReturnValue(false); | ||
const examples: GithubContent[] = [ | ||
{ name: 'test-1', path: 'test-1', type: 'dir' }, | ||
{ name: 'test-2', path: 'test-2', type: 'dir' }, | ||
]; | ||
|
||
fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve(examples) } as any); | ||
prompts.mockResolvedValue({ answer: 'test-1' }); | ||
|
||
await expect(promptExamplesAsync()).resolves.toBe('test-1'); | ||
expect(prompts).toHaveBeenCalledWith( | ||
expect.objectContaining({ | ||
choices: expect.arrayContaining([ | ||
{ title: 'test-1', value: 'test-1' }, | ||
{ title: 'test-2', value: 'test-2' }, | ||
]), | ||
}) | ||
); | ||
|
||
spy.mockRestore(); | ||
}); | ||
}); | ||
|
||
describe(sanitizeScriptsAsync, () => { | ||
afterEach(() => vol.reset()); | ||
|
||
it('adds default scripts for managed apps', async () => { | ||
vol.fromJSON({ | ||
'/project/package.json': JSON.stringify({ | ||
name: 'project', | ||
version: '0.0.0', | ||
}), | ||
}); | ||
|
||
await sanitizeScriptsAsync('/project'); | ||
const packageJson = JSON.parse(String(vol.readFileSync('/project/package.json'))); | ||
|
||
expect(packageJson.scripts).toMatchObject({ | ||
start: 'expo start', | ||
android: 'expo start --android', | ||
ios: 'expo start --ios', | ||
web: 'expo start --web', | ||
}); | ||
}); | ||
|
||
it('adds default scripts for bare apps', async () => { | ||
vol.fromJSON({ | ||
'/project/android/build.gradle': 'fake-gradle', | ||
'/project/ios/Podfile': 'fake-podfile', | ||
'/project/package.json': JSON.stringify({ | ||
name: 'project', | ||
version: '0.0.0', | ||
}), | ||
}); | ||
|
||
await sanitizeScriptsAsync('/project'); | ||
const packageJson = JSON.parse(String(vol.readFileSync('/project/package.json'))); | ||
|
||
expect(packageJson.scripts).toMatchObject({ | ||
start: 'expo start --dev-client', | ||
android: 'expo run:android', | ||
ios: 'expo run:ios', | ||
web: 'expo start --web', | ||
}); | ||
}); | ||
|
||
it('does not overwrite existing scripts', async () => { | ||
vol.fromJSON({ | ||
'/project/package.json': JSON.stringify({ | ||
name: 'project', | ||
version: '0.0.0', | ||
scripts: { | ||
start: 'node start.js', | ||
}, | ||
}), | ||
}); | ||
|
||
await sanitizeScriptsAsync('/project'); | ||
const packageJson = JSON.parse(String(vol.readFileSync('/project/package.json'))); | ||
|
||
expect(packageJson.scripts).toMatchObject({ | ||
start: 'node start.js', | ||
android: 'expo start --android', | ||
ios: 'expo start --ios', | ||
web: 'expo start --web', | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.