one
Version:
One is a React framework for web, iOS, and Android apps.
444 lines (420 loc) • 17.4 kB
text/typescript
// packed-artifact closure oracle. packs the real workspace tarballs, unpacks
// them into isolated temp dirs, and proves a tarball-only consumer imports
// the foundation contracts through package exports with no workspace path.
import { execFileSync } from 'node:child_process'
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
renameSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { auditUnpackedManifest, findForbiddenDependencies } from './closure'
import { loadUserOneOptions } from '../vite/loadConfig'
const here = dirname(fileURLToPath(import.meta.url))
const oneDir = resolve(here, '../..')
const vxrnDir = resolve(here, '../../../vxrn')
const vitePluginMetroDir = resolve(here, '../../../vite-plugin-metro')
const utilsDir = resolve(here, '../../../utils')
const workspaceRoot = resolve(here, '../../..')
const basicStarterDir = resolve(workspaceRoot, '../examples/one-basic')
const KNOWN_ONE_BLOCKERS: string[] = []
const KNOWN_VXRN_BLOCKERS: string[] = []
const KNOWN_VITE_PLUGIN_METRO_BLOCKERS: string[] = []
function packToDir(packageDir: string, outDir: string): string {
const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'))
const fileName = `${manifest.name.replace('@', '').replace('/', '-')}-${manifest.version}.tgz`
execFileSync('bun', ['pm', 'pack', '--destination', outDir], {
cwd: packageDir,
encoding: 'utf8',
})
const tarball = join(outDir, fileName)
expect(existsSync(tarball)).toBe(true)
return tarball
}
// packs each package and every workspace package it depends on, so a consumer
// installs the release closure itself and never an older or missing npm version
function packWorkspaceClosure(packageDirs: string[], outDir: string) {
const workspaces = new Map<string, string>()
for (const entry of readdirSync(workspaceRoot, { withFileTypes: true })) {
const packageDir = join(workspaceRoot, entry.name)
const manifestPath = join(packageDir, 'package.json')
if (!entry.isDirectory() || !existsSync(manifestPath)) continue
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
workspaces.set(manifest.name, packageDir)
}
const tarballs: Record<string, string> = {}
const pending = [...packageDirs]
for (const packageDir of pending) {
const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'))
if (tarballs[manifest.name]) continue
tarballs[manifest.name] = packToDir(packageDir, outDir)
for (const name of Object.keys({
...manifest.dependencies,
...manifest.optionalDependencies,
...manifest.peerDependencies,
})) {
const dependencyDir = workspaces.get(name)
if (dependencyDir) pending.push(dependencyDir)
}
}
return tarballs
}
function unpack(tarball: string, destDir: string): string {
mkdirSync(destDir, { recursive: true })
execFileSync('tar', ['-xzf', tarball, '-C', destDir])
return join(destDir, 'package')
}
function getInstalledPackageNames(nodeModulesDir: string): string[] {
const names: string[] = []
const seen = new Set<string>()
const visitPackage = (packageDir: string) => {
const realDir = realpathSync(packageDir)
if (seen.has(realDir)) return
seen.add(realDir)
const manifest = JSON.parse(readFileSync(join(realDir, 'package.json'), 'utf8'))
if (typeof manifest.name === 'string') names.push(manifest.name)
visitNodeModules(join(realDir, 'node_modules'))
}
const visitNodeModules = (dir: string) => {
if (!existsSync(dir)) return
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith('.')) continue
const entryPath = join(dir, entry.name)
if (entry.name.startsWith('@')) {
for (const child of readdirSync(entryPath, { withFileTypes: true })) {
if (child.isDirectory() || child.isSymbolicLink()) {
visitPackage(join(entryPath, child.name))
}
}
} else if (entry.isDirectory() || entry.isSymbolicLink()) {
visitPackage(entryPath)
}
}
}
visitNodeModules(nodeModulesDir)
return names.sort()
}
describe('packed-artifact closure oracle', () => {
it('keeps the packed framework manifests free of Expo dependencies', () => {
const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'one-packed-closure-')))
const onePkg = JSON.parse(
readFileSync(
join(unpack(packToDir(oneDir, tmp), join(tmp, 'one')), 'package.json'),
'utf8'
)
)
expect(auditUnpackedManifest(onePkg)).toEqual(KNOWN_ONE_BLOCKERS)
expect(onePkg.dependencies).not.toHaveProperty(
'@react-native-masked-view/masked-view'
)
expect(onePkg.peerDependencies).not.toHaveProperty('react-native-safe-area-context')
const vxrnExtracted = unpack(packToDir(vxrnDir, tmp), join(tmp, 'vxrn'))
const vxrnPkg = JSON.parse(readFileSync(join(vxrnExtracted, 'package.json'), 'utf8'))
expect(auditUnpackedManifest(vxrnPkg)).toEqual(KNOWN_VXRN_BLOCKERS)
expect(vxrnPkg.exports['./expo-plugin']).toBe('./expo-plugin.cjs')
expect(existsSync(join(vxrnExtracted, 'expo-plugin.cjs'))).toBe(true)
expect(existsSync(join(vxrnExtracted, 'native-project-patches.cjs'))).toBe(true)
const vitePluginMetroPkg = JSON.parse(
readFileSync(
join(
unpack(packToDir(vitePluginMetroDir, tmp), join(tmp, 'vite-plugin-metro')),
'package.json'
),
'utf8'
)
)
expect(auditUnpackedManifest(vitePluginMetroPkg)).toEqual(
KNOWN_VITE_PLUGIN_METRO_BLOCKERS
)
})
it('ships the one/native entrypoint with dist and types in the tarball', () => {
const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'one-packed-native-')))
const extracted = unpack(packToDir(oneDir, tmp), join(tmp, 'one'))
for (const target of [
// one's own native code, autolinked as the `one` dependency
'One.podspec',
'react-native.config.cjs',
'android/build.gradle',
'android/CMakeLists.txt',
'nitrogen/generated/ios/One+autolinking.rb',
'dist/esm/safe-area-context/index.native.js',
'dist/esm/native/index.mjs',
'dist/cjs/native/index.cjs',
'types/native/index.d.ts',
]) {
expect(existsSync(join(extracted, target))).toBe(true)
}
const dts = readFileSync(join(extracted, 'types/native/index.d.ts'), 'utf8')
for (const name of [
'NativeAppManifest',
'ONE_PUBLIC_PREFIX',
'ONE_PLATFORM_ENV',
'pickOnePublicEnv',
'validateNativeApp',
]) {
expect(dts).toContain(name)
}
// the closure oracle stays test-internal: one/native publishes only the
// app and env contracts, never a customer-facing Expo ban.
for (const name of [
'createResolutionRecorder',
'findForbiddenDependencies',
'isForbiddenExpoSpecifier',
'FORBIDDEN_EXPO_PATTERN_SOURCES',
'OneNativePlatform',
'selectOneNativePlatform',
]) {
expect(dts).not.toContain(name)
}
const exportsMap = JSON.parse(
readFileSync(join(extracted, 'package.json'), 'utf8')
).exports
expect(exportsMap['./native'].import).toBe('./dist/esm/native/index.mjs')
})
it('imports one/native in a tarball-only consumer with no workspace path', () => {
const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'one-packed-consumer-')))
const consumerModules = join(tmp, 'consumer', 'node_modules')
mkdirSync(consumerModules, { recursive: true })
const extracted = join(consumerModules, 'one')
renameSync(unpack(packToDir(oneDir, tmp), join(tmp, 'one')), extracted)
mkdirSync(join(consumerModules, '@vxrn'), { recursive: true })
renameSync(
unpack(packToDir(utilsDir, tmp), join(tmp, 'utils')),
join(consumerModules, '@vxrn', 'utils')
)
const script = `
const url = await import.meta.resolve('one/native');
if (url.includes(${JSON.stringify(workspaceRoot)})) throw new Error('resolved into the workspace: ' + url);
const utilsUrl = await import.meta.resolve('@vxrn/utils/publicEnv');
if (utilsUrl.includes(${JSON.stringify(workspaceRoot)})) throw new Error('utils resolved into the workspace: ' + utilsUrl);
const m = await import('one/native');
m.validateNativeApp({ name: 'T', ios: { bundleId: 'a.b' }, android: { applicationId: 'a.b' } });
if (m.pickOnePublicEnv({ ONE_PUBLIC_A: '1' }).ONE_PUBLIC_A !== '1') throw new Error('env contract broken');
if (m.ONE_PUBLIC_PREFIX !== 'ONE_PUBLIC_' || m.ONE_PLATFORM_ENV !== 'ONE_PLATFORM') throw new Error('env names broken');
if ('createResolutionRecorder' in m || 'selectOneNativePlatform' in m || 'findForbiddenDependencies' in m) throw new Error('test-only oracle leaked into one/native');
console.log('one/native ok ' + url);
`
const out = execFileSync(
process.execPath,
['--input-type=module', '--eval', script],
{ cwd: join(tmp, 'consumer'), encoding: 'utf8' }
)
expect(out).toContain('one/native ok')
expect(out).not.toContain(workspaceRoot)
})
it('keeps the default packed closure Expo-free and bundles Metro after its preset is installed', () => {
const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'one-packed-metro-')))
const tarballs = join(tmp, 'tarballs')
mkdirSync(tarballs)
const packedClosure = packWorkspaceClosure(
[],
tarballs
)
const appDir = join(tmp, 'app')
mkdirSync(join(appDir, 'app'), { recursive: true })
writeFileSync(join(appDir, '.watchmanconfig'), '{}\n')
writeFileSync(
join(appDir, 'package.json'),
JSON.stringify(
{
private: true,
type: 'module',
dependencies: {
one: `file:${packedClosure.one}`,
vxrn: `file:${packedClosure.vxrn}`,
'@vxrn/vite-plugin-metro': `file:${packedClosure['@vxrn/vite-plugin-metro']}`,
'@vxrn/utils': `file:${packedClosure['@vxrn/utils']}`,
react: '19.2.3',
'react-native': '0.87.1',
'react-native-web': '^0.21.2',
vite: '^8.2.2',
metro: '^0.87.0',
'metro-config': '^0.87.0',
},
overrides: Object.fromEntries(
Object.entries(packedClosure).map(([name, tarball]) => [
name,
`file:${tarball}`,
])
),
},
null,
2
)
)
writeFileSync(
join(appDir, 'babel.config.cjs'),
`const oneBabelPreset = require('one/babel-preset')
const preset = oneBabelPreset.default || oneBabelPreset
module.exports = function (api) {
return preset(api, { projectRoot: __dirname })
}\n`
)
writeFileSync(
join(appDir, 'metro.config.cjs'),
`const { withOne } = require('one/metro-config')
module.exports = withOne(__dirname, { loadViteConfig: false })
`
)
writeFileSync(
join(appDir, 'tsconfig.json'),
JSON.stringify({ compilerOptions: { baseUrl: '.', paths: {} } })
)
writeFileSync(
join(appDir, 'app/index.tsx'),
`import React from 'react'
import { Text } from 'react-native'
import { One } from 'one'
export default function App() {
void One.iOS.Button
void One.Android.Button
void One.UI.Blur
void One.UI.SafeArea.View
return <Text>packed metro ok</Text>
}
`
)
execFileSync('bun', ['install', '--ignore-scripts'], {
cwd: appDir,
encoding: 'utf8',
timeout: 180_000,
})
const installedNames = getInstalledPackageNames(join(appDir, 'node_modules'))
expect(findForbiddenDependencies(installedNames)).toEqual([])
for (const [name, tarball] of Object.entries(packedClosure)) {
const installedDir = realpathSync(join(appDir, 'node_modules', name))
expect(installedDir.startsWith(`${appDir}/node_modules/`)).toBe(true)
const packedDir = unpack(tarball, join(tmp, 'expected', name))
expect(
JSON.parse(readFileSync(join(installedDir, 'package.json'), 'utf8'))
).toEqual(JSON.parse(readFileSync(join(packedDir, 'package.json'), 'utf8')))
}
const resolutionScript = `
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
for (const specifier of [
'one/package.json',
'vxrn/package.json',
'@vxrn/vite-plugin-metro/package.json',
'@react-native/metro-config/package.json',
'@react-native/babel-preset/package.json',
]) {
const url = await import.meta.resolve(specifier)
if (url.includes(${JSON.stringify(workspaceRoot)})) {
throw new Error('resolved into the workspace: ' + url)
}
}
const config = require('one/react-native-config')
const fs = require('node:fs')
const path = require('node:path')
const root = process.cwd()
fs.mkdirSync(path.join(root, 'ios', 'Packed.xcodeproj'), {recursive: true})
fs.writeFileSync(path.join(root, 'ios', 'Podfile'), "target 'Packed' do\\nend\\n")
fs.mkdirSync(path.join(root, 'android', 'app', 'src', 'main'), {recursive: true})
fs.writeFileSync(path.join(root, 'android', 'app', 'build.gradle'), 'android { namespace "dev.one.packed" }')
fs.writeFileSync(path.join(root, 'android', 'app', 'src', 'main', 'AndroidManifest.xml'), '<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="dev.one.packed"><application /></manifest>')
const ios = config.platforms.ios.projectConfig(root, {})
const android = config.platforms.android.projectConfig(root, {})
if (ios.sourceDir !== path.join(root, 'ios') || android.sourceDir !== path.join(root, 'android')) {
throw new Error('native config did not discover both app-owned projects')
}
for (const name of ['run-ios', 'run-android', 'bundle']) {
if (!config.commands.some(command => command.name === name)) {
throw new Error('native config is missing command ' + name)
}
}
for (const [name, dependency] of Object.entries(config.dependencies)) {
// a package one absorbs has no root: the entry only turns its native code off
if (dependency.root === undefined) {
if (dependency.platforms?.ios !== null || dependency.platforms?.android !== null) {
throw new Error('react-native config entry ' + name + ' neither links nor disables native code')
}
continue
}
if (dependency.root.includes(${JSON.stringify(workspaceRoot)})) {
throw new Error('react-native config resolved ' + name + ' into the workspace: ' + dependency.root)
}
if (require(dependency.root + '/package.json').name !== name) {
throw new Error('react-native config resolved ' + name + ' to the wrong package')
}
}
if (!config.dependencies['react-native-nitro-modules']) {
throw new Error('react-native config does not link one native dependencies')
}
`
execFileSync(process.execPath, ['--input-type=module', '--eval', resolutionScript], {
cwd: appDir,
encoding: 'utf8',
})
expect(() =>
execFileSync(
process.execPath,
['-e', "require('./metro.config.cjs').catch((error) => { console.error(error.message); process.exitCode = 1 })"],
{ cwd: appDir, encoding: 'utf8' }
)
).toThrow(/Metro requires babel-preset-expo.*Install it/)
const expoPresetVersion = JSON.parse(
readFileSync(join(workspaceRoot, '../node_modules/babel-preset-expo/package.json'), 'utf8')
).version
execFileSync('bun', ['add', '-d', `babel-preset-expo@${expoPresetVersion}`], {
cwd: appDir,
encoding: 'utf8',
timeout: 180_000,
})
expect(getInstalledPackageNames(join(appDir, 'node_modules'))).toContain('babel-preset-expo')
for (const dev of ['true', 'false']) {
const bundlePath = join(appDir, `bundle.${dev}.js`)
execFileSync(
join(appDir, 'node_modules/.bin/metro'),
[
'build',
'node_modules/one/metro-entry.js',
'--config',
'metro.config.cjs',
'--platform',
'ios',
'--dev',
dev,
'--minify',
dev === 'false' ? 'true' : 'false',
'--out',
bundlePath,
],
{ cwd: appDir, encoding: 'utf8', timeout: 180_000 }
)
expect(readFileSync(bundlePath).byteLength).toBeGreaterThan(1_000_000)
}
})
it('keeps the generated Basic starter on the zero-Expo One contract', async () => {
const packageJson = JSON.parse(
readFileSync(join(basicStarterDir, 'package.json'), 'utf8')
)
expect(auditUnpackedManifest(packageJson)).toEqual([])
expect(packageJson.dependencies).not.toHaveProperty('react-native-safe-area-context')
expect(packageJson.devDependencies).toHaveProperty('@react-native-community/template')
expect(existsSync(join(basicStarterDir, 'app.json'))).toBe(false)
const previousCwd = process.cwd()
try {
process.chdir(basicStarterDir)
const rolldown = await loadUserOneOptions('build', true)
expect(rolldown.oneOptions.native).toMatchObject({
app: {
name: 'OneBasic',
ios: { bundleId: 'dev.onestack.starter' },
android: { applicationId: 'dev.onestack.starter' },
},
})
expect(rolldown.oneOptions.native).not.toHaveProperty('bundler')
} finally {
process.chdir(previousCwd)
}
})
}, 180000)