TripoSplat-WebGPU / scripts /test_packed_vite_consumer.mjs
Yosun's picture
Deploy TripoSplat WebGPU demo
31c7d49 verified
Raw
History Blame Contribute Delete
10 kB
import { spawnSync } from 'node:child_process'
import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const viteFixtureRoot = join(repositoryRoot, 'tests/consumers/vite')
const nextClientFixtureRoot = join(repositoryRoot, 'tests/consumers/next-client')
const importMapFixtureRoot = join(repositoryRoot, 'tests/consumers/import-map')
const temporaryRoot = await mkdtemp(join(tmpdir(), 'triposplat-vite-consumer-'))
const packRoot = join(temporaryRoot, 'packs')
const consumerRoot = join(temporaryRoot, 'consumer')
const importMapRoot = join(temporaryRoot, 'import-map')
const keepTemporary = process.env.KEEP_PACKAGE_CONSUMER_TEMP === '1'
let failed = false
function executable(name) {
return process.platform === 'win32' ? `${name}.cmd` : name
}
function run(command, args, cwd = repositoryRoot) {
process.stdout.write(`\n> ${command} ${args.join(' ')}\n`)
const result = spawnSync(executable(command), args, {
cwd,
env: process.env,
stdio: 'inherit',
})
if (result.error) throw result.error
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} exited with status ${result.status}.`)
}
}
async function archiveContaining(fragment) {
const names = await readdir(packRoot)
const matches = names.filter((name) => name.includes(fragment) && name.endsWith('.tgz'))
if (matches.length !== 1) {
throw new Error(`Expected one '${fragment}' tarball in ${packRoot}; found ${matches.length}.`)
}
return join(packRoot, matches[0])
}
async function copyFixture(sourceRoot, destinationRoot) {
await cp(sourceRoot, destinationRoot, {
recursive: true,
filter(source) {
const name = basename(source)
return name !== 'node_modules'
&& name !== 'dist'
&& name !== '.next'
&& name !== 'package-lock.json'
},
})
}
async function filesBelow(root) {
const output = []
for (const entry of await readdir(root, { withFileTypes: true })) {
const path = join(root, entry.name)
if (entry.isDirectory()) output.push(...await filesBelow(path))
else output.push(path)
}
return output
}
async function validateBundle() {
const distRoot = join(consumerRoot, 'dist')
const files = await filesBelow(distRoot)
if (!files.some((file) => file.endsWith('.wasm'))) {
throw new Error('The installed-package Vite build omitted the ONNX Runtime WASM asset.')
}
let containsWorker = false
for (const file of files.filter((path) => path.endsWith('.js'))) {
const details = await stat(file)
if (details.size > 2_000_000) continue
const source = await readFile(file, 'utf8')
if (source.includes('triposplat-onnx-webgpu') || source.includes('TripoSplat runtime worker')) {
containsWorker = true
break
}
}
if (!containsWorker) {
throw new Error('The installed-package Vite build omitted the TripoSplat module worker.')
}
const relativeFiles = files.map((file) => file.slice(distRoot.length + 1)).sort()
process.stdout.write(`\nFresh Vite consumer build passed in ${consumerRoot}.\n`)
process.stdout.write(`Built assets:\n${relativeFiles.map((file) => ` ${file}`).join('\n')}\n`)
}
function assertInside(root, path, label) {
const pathFromRoot = relative(root, path)
if (pathFromRoot === '' || (!pathFromRoot.startsWith('..') && !isAbsolute(pathFromRoot))) return
throw new Error(`${label} resolves outside the static consumer root: ${path}`)
}
function resolveImportSpecifier(specifier, parentFile, imports) {
let target
if (specifier.startsWith('./') || specifier.startsWith('../')) {
target = resolve(dirname(parentFile), specifier)
} else {
const exact = imports[specifier]
if (typeof exact === 'string') {
target = resolve(importMapRoot, exact)
} else {
const prefix = Object.keys(imports)
.filter((key) => key.endsWith('/') && specifier.startsWith(key))
.sort((left, right) => right.length - left.length)[0]
const mappedPrefix = prefix === undefined ? undefined : imports[prefix]
if (typeof prefix !== 'string' || typeof mappedPrefix !== 'string') {
throw new Error(`No import-map entry resolves '${specifier}' imported by ${parentFile}.`)
}
target = resolve(importMapRoot, mappedPrefix, specifier.slice(prefix.length))
}
}
assertInside(importMapRoot, target, `Module '${specifier}'`)
return target
}
function staticModuleSpecifiers(source) {
const specifiers = []
const pattern = /\b(?:import|export)\s+(?:(?:[^"'();]*?)\s+from\s+)?["']([^"']+)["']/g
for (const match of source.matchAll(pattern)) specifiers.push(match[1])
return specifiers
}
async function validateMappedPackageExports(packageName, packageRoot, imports) {
const manifest = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'))
for (const [subpath, descriptor] of Object.entries(manifest.exports ?? {})) {
const importTarget = typeof descriptor === 'string' ? descriptor : descriptor?.import
if (typeof importTarget !== 'string') continue
const specifier = subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}`
const mapped = imports[specifier]
if (typeof mapped !== 'string') {
throw new Error(`The native ESM fixture does not map public export '${specifier}'.`)
}
const expected = resolve(packageRoot, importTarget)
const actual = resolve(importMapRoot, mapped)
if (actual !== expected) {
throw new Error(`Import-map target for '${specifier}' is '${actual}', expected '${expected}'.`)
}
await stat(actual)
}
}
async function prepareImportMapConsumer() {
await copyFixture(importMapFixtureRoot, importMapRoot)
const scopeRoot = join(importMapRoot, 'vendor/@ai3d')
await mkdir(scopeRoot, { recursive: true })
await cp(
join(consumerRoot, 'node_modules/@ai3d/gaussian-scene'),
join(scopeRoot, 'gaussian-scene'),
{ recursive: true },
)
await cp(
join(consumerRoot, 'node_modules/@ai3d/triposplat-webgpu'),
join(scopeRoot, 'triposplat-webgpu'),
{ recursive: true },
)
}
async function validateImportMapConsumer() {
const htmlPath = join(importMapRoot, 'index.html')
const html = await readFile(htmlPath, 'utf8')
const importMapMatch = /<script\s+type=["']importmap["'][^>]*>([\s\S]*?)<\/script>/i.exec(html)
const moduleMatch = /<script\s+type=["']module["']\s+src=["']([^"']+)["'][^>]*><\/script>/i.exec(html)
if (!importMapMatch || !moduleMatch) {
throw new Error('Native ESM fixture needs an import map followed by an external module script.')
}
if ((importMapMatch.index ?? 0) > (moduleMatch.index ?? 0)) {
throw new Error('The import map must precede the module script in the HTML document.')
}
const parsed = JSON.parse(importMapMatch[1])
const imports = parsed.imports
if (!imports || typeof imports !== 'object' || Array.isArray(imports)) {
throw new Error('Native ESM fixture has no valid import-map imports object.')
}
const triposplatRoot = join(importMapRoot, 'vendor/@ai3d/triposplat-webgpu')
const gaussianSceneRoot = join(importMapRoot, 'vendor/@ai3d/gaussian-scene')
await validateMappedPackageExports('@ai3d/triposplat-webgpu', triposplatRoot, imports)
await validateMappedPackageExports('@ai3d/gaussian-scene', gaussianSceneRoot, imports)
const requiredAssets = [
join(triposplatRoot, 'dist/worker.js'),
join(triposplatRoot, 'dist/ort/ort-wasm-simd-threaded.asyncify.mjs'),
join(triposplatRoot, 'dist/ort/ort-wasm-simd-threaded.asyncify.wasm'),
]
for (const asset of requiredAssets) await stat(asset)
const entry = resolve(importMapRoot, moduleMatch[1])
assertInside(importMapRoot, entry, 'Module entry')
const pending = [entry]
const visited = new Set()
while (pending.length > 0) {
const modulePath = pending.pop()
if (visited.has(modulePath)) continue
visited.add(modulePath)
const source = await readFile(modulePath, 'utf8')
for (const specifier of staticModuleSpecifiers(source)) {
const dependency = resolveImportSpecifier(specifier, modulePath, imports)
await stat(dependency)
if (dependency.endsWith('.js') || dependency.endsWith('.mjs')) pending.push(dependency)
}
}
process.stdout.write(`\nPacked native ESM/import-map graph passed (${visited.size} JavaScript modules).\n`)
process.stdout.write('This build-time check does not launch a browser or run WebGPU inference.\n')
}
try {
await mkdir(packRoot, { recursive: true })
run('pnpm', ['--filter', '@ai3d/gaussian-scene', 'pack', '--pack-destination', packRoot])
run('pnpm', ['--filter', '@ai3d/triposplat-webgpu', 'pack', '--pack-destination', packRoot])
const sceneArchive = await archiveContaining('gaussian-scene')
const coreArchive = await archiveContaining('triposplat-webgpu')
await copyFixture(viteFixtureRoot, consumerRoot)
await copyFixture(nextClientFixtureRoot, join(consumerRoot, 'next-client'))
run('npm', [
'install',
'--ignore-scripts',
'--no-audit',
'--no-fund',
'--no-package-lock',
sceneArchive,
coreArchive,
], consumerRoot)
run('npm', ['run', 'build'], consumerRoot)
await validateBundle()
run('npm', ['run', 'typecheck:next-client'], consumerRoot)
process.stdout.write('\nPacked Next-style client component typecheck passed.\n')
process.stdout.write('This check does not install or invoke Next.js/Turbopack.\n')
await prepareImportMapConsumer()
await validateImportMapConsumer()
} catch (error) {
failed = true
process.stderr.write(`\nPacked package consumer smoke failed. Temporary files: ${temporaryRoot}\n`)
throw error
} finally {
if (!keepTemporary && !failed) await rm(temporaryRoot, { recursive: true, force: true })
else process.stdout.write(`Temporary files retained at ${temporaryRoot}\n`)
}