UNPKG

one

Version:

One is a React framework for web, iOS, and Android apps.

444 lines (420 loc) • 17.4 kB
// 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( [oneDir, vxrnDir, vitePluginMetroDir, utilsDir], 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)