diff --git a/app/components/Package/SizeIncrease.vue b/app/components/Package/SizeIncrease.vue index b35e4b48d7..d07d80e10a 100644 --- a/app/components/Package/SizeIncrease.vue +++ b/app/components/Package/SizeIncrease.vue @@ -1,8 +1,11 @@ + + + + diff --git a/app/composables/pkg-size/useAnalyzeCauseWorker.ts b/app/composables/pkg-size/useAnalyzeCauseWorker.ts new file mode 100644 index 0000000000..2a4af09842 --- /dev/null +++ b/app/composables/pkg-size/useAnalyzeCauseWorker.ts @@ -0,0 +1,236 @@ +import type { ComputedRef } from 'vue' +import type { + AnalyzeWorkerResponse, + DiffResult, + UIDiffResult, + UISummary, +} from '~/utils/pkg-size/types' +import { shallowRef, onMounted, onUnmounted } from 'vue' +import { useThrottleFn } from '@vueuse/core' +import { useBytesFormatter, useNumberFormatter } from '~/composables/useNumberFormatter' + +export function useAnalyzeCauseWorker( + packageName: ComputedRef, + version: ComputedRef, + comparedVersion: ComputedRef, +) { + const analyzing = shallowRef(false) + const available = shallowRef(false) + const loading = shallowRef(true) + const cancelling = shallowRef(false) + const noResultScroll = shallowRef(false) + const allDependencies = shallowRef(true) + + const rawResult = shallowRef([]) + const result = computed(() => { + const all = allDependencies.value + const raw = rawResult.value + if (all || raw.length === 0) { + return raw + } + return raw.filter(v => !v.isOptional) + }) + const summary = shallowRef() + const error = shallowRef() + + if (!import.meta.dev && import.meta.server) { + return { + available, + analyzing, + cancelling, + loading, + result, + noResultScroll, + allDependencies, + summary, + error, + startAnalyzeCause: async () => {}, + cancelAnalyzeCause: async () => {}, + } + } + + let worker: Worker | undefined + let currentId: number | string = 0 + + const bytesFormatter = useBytesFormatter() + const rawBytesFormatter = useNumberFormatter() + + watch( + [loading, packageName, version, comparedVersion], + ([l, pkg, v1, v2]) => { + available.value = !l && !!pkg && !!v1 && !!v2 && !!worker + }, + { flush: 'post' }, + ) + + function handleWorkerFailure(event: Event) { + error.value = event instanceof ErrorEvent ? event.message : 'worker failure' + analyzing.value = false + cancelling.value = false + // oxlint-disable-next-line no-console + console.error('Worker failure:', event) + } + + const startAnalyzeCause = useThrottleFn( + async () => { + if (!worker || !available.value || analyzing.value) { + return + } + + analyzing.value = true + currentId = + import.meta.test || import.meta.dev ? (currentId as number) + 1 : crypto.randomUUID() + cancelling.value = false + error.value = undefined + summary.value = undefined + rawResult.value = [] + + await new Promise(resolve => setTimeout(resolve, 256)) + + worker.postMessage({ + type: 'analyze-cause', + id: currentId, + packageName: packageName.value, + fromVersion: comparedVersion.value, + toVersion: version.value, + ignoreOptional: false, + }) + }, + 256, + false, + true, + ) + + const cancelAnalyzeCause = useThrottleFn( + async () => { + if (!worker || !analyzing.value || cancelling.value) { + return + } + + cancelling.value = true + + await new Promise(resolve => setTimeout(resolve, 256)) + + worker.postMessage({ + type: 'analyze-cause-abort', + id: currentId, + }) + }, + 256, + false, + true, + ) + + function getDiffResultStatusText(diffResult: DiffResult): string { + switch (diffResult.status) { + case 'added': + return bytesFormatter.t('package.size_increase.analyze.status.added') + case 'changed': + return bytesFormatter.t('package.size_increase.analyze.status.changed') + case 'removed': + return bytesFormatter.t('package.size_increase.analyze.status.removed') + case 'unchanged': + return bytesFormatter.t('package.size_increase.analyze.status.unchanged') + } + } + + async function handleWorkerMessage(event: MessageEvent) { + const msg = event.data + + if (msg.id !== currentId) { + return + } + + switch (msg.type) { + case 'sessions': + break + case 'result': + await new Promise(resolve => setTimeout(resolve, 1_000)) + rawResult.value = msg.result.map( + r => + Object.assign(r, { + v1: r.v1 ? { ...r.v1, sizeText: bytesFormatter.format(r.v1.size) } : null, + v2: r.v2 ? { ...r.v2, sizeText: bytesFormatter.format(r.v2.size) } : null, + statusText: getDiffResultStatusText(r), + sizeDeltaText: bytesFormatter.format(r.sizeDelta), + }) as UIDiffResult, + ) + summary.value = Object.assign(msg.summary, { + sizeDeltaText: bytesFormatter.format(msg.summary.sizeDelta), + sizeDeltaBytesText: rawBytesFormatter.value.format(msg.summary.sizeDelta), + mandatorySizeDeltaText: bytesFormatter.format(msg.summary.mandatorySizeDelta), + mandatorySizeDeltaBytesText: rawBytesFormatter.value.format( + msg.summary.mandatorySizeDelta, + ), + netDependenciesText: `${msg.summary.netDependencies > 0 ? '+' : ''}${rawBytesFormatter.value.format(msg.summary.netDependencies)}`, + addedText: rawBytesFormatter.value.format(msg.summary.added), + removedText: rawBytesFormatter.value.format(msg.summary.removed), + }) + analyzing.value = false + cancelling.value = false + break + case 'error': + error.value = msg.message + analyzing.value = false + cancelling.value = false + // oxlint-disable-next-line no-console + console.error('Worker failure:', msg) + break + case 'aborting': + cancelling.value = true + break + case 'aborted': + analyzing.value = false + cancelling.value = false + break + } + } + + onMounted(async () => { + try { + const module = await import('~/utils/pkg-size/analyze-cause-client-worker') + worker = module.worker + + worker.addEventListener('message', handleWorkerMessage) + worker.addEventListener('error', handleWorkerFailure) + worker.addEventListener('messageerror', handleWorkerFailure) + } catch (err) { + // oxlint-disable-next-line no-console + console.error('cannot load worker', err) + error.value = + err instanceof Error + ? err.message + : typeof err === 'object' && err !== null && 'message' in err + ? String(err.message) + : String(err) + } finally { + loading.value = false + } + }) + + onUnmounted(() => { + if (worker) { + // send abort first, then remove listeners: the aborting message won't be received here (fire and forgot) + if (analyzing.value) { + worker.postMessage({ type: 'analyze-cause-abort', id: currentId }) + } + worker.removeEventListener('message', handleWorkerMessage) + worker.removeEventListener('error', handleWorkerFailure) + worker.removeEventListener('messageerror', handleWorkerFailure) + } + }) + + return { + available, + analyzing, + cancelling, + loading, + result, + noResultScroll, + allDependencies, + summary, + error, + startAnalyzeCause, + cancelAnalyzeCause, + } +} diff --git a/app/composables/useNumberFormatter.ts b/app/composables/useNumberFormatter.ts index a375bc0431..196b381c28 100644 --- a/app/composables/useNumberFormatter.ts +++ b/app/composables/useNumberFormatter.ts @@ -20,15 +20,20 @@ export const useBytesFormatter = () => { const MB = 1000 * 1000 return { + t, format: (bytes: number) => { - if (bytes < KB) + const magnitude = Math.abs(bytes) + if (magnitude < KB) { return t('package.size.b', { size: decimalNumberFormatter.value.format(bytes), }) - if (bytes < MB) + } + if (magnitude < MB) { return t('package.size.kb', { size: decimalNumberFormatter.value.format(bytes / KB), }) + } + return t('package.size.mb', { size: decimalNumberFormatter.value.format(bytes / MB), }) diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 23de9e3b0b..943f02df70 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -920,7 +920,12 @@ const showSkeleton = shallowRef(false) :replacement="moduleReplacement.replacement" /> - + diff --git a/app/utils/pkg-size/analyze-cause-client-worker.ts b/app/utils/pkg-size/analyze-cause-client-worker.ts new file mode 100644 index 0000000000..3609a871a2 --- /dev/null +++ b/app/utils/pkg-size/analyze-cause-client-worker.ts @@ -0,0 +1,5 @@ +import Worker from '~/utils/pkg-size/analyze-cause-worker?worker' + +export const worker = new Worker({ + name: 'NpmxPkgSizeAnalyzeCauseWorker', +}) diff --git a/app/utils/pkg-size/analyze-cause-worker.ts b/app/utils/pkg-size/analyze-cause-worker.ts new file mode 100644 index 0000000000..76a925c2d9 --- /dev/null +++ b/app/utils/pkg-size/analyze-cause-worker.ts @@ -0,0 +1,199 @@ +import type { + AnalysisSessionEntity, + AnalyzeCauseWorkerRequest, + AnalyzeWorkerResponse, + DiffResult, + PackageEntity, +} from '~/utils/pkg-size/types' +import { AbortedError, checkAborted } from '~/utils/pkg-size/check-aborted' +import { db } from '~/utils/pkg-size/db' +import { resolveAndPersistGraph } from '~/utils/pkg-size/resolve-and-persist-graph' + +let abortController: AbortController | undefined +let inFlightId: number | string | undefined + +function post(msg: AnalyzeWorkerResponse) { + ;(self as unknown as Worker).postMessage(msg) +} + +self.addEventListener('message', async (event: MessageEvent) => { + const msg = event.data + const { id, type } = msg + + if (type === 'analyze-cause-abort') { + if (abortController && inFlightId === id) { + abortController.abort() + post({ type: 'aborting', id }) + } else { + post({ type: 'aborted', id }) + } + return + } + + if (type !== 'analyze-cause') { + return + } + + const { packageName, fromVersion, toVersion, ignoreOptional = false } = msg + + abortController?.abort() + const controller = new AbortController() + abortController = controller + inFlightId = id + + const keys = { + fromVersion: `${packageName}@${fromVersion}`, + toVersion: `${packageName}@${toVersion}`, + } + + try { + const existing = (await db.getSessions([keys.fromVersion, keys.toVersion])) || [] + const existingKeys = new Set(existing.map(s => s.rootKey)) + + const keysToInit: string[] = [] + if (!existingKeys.has(keys.fromVersion)) { + keysToInit.push(keys.fromVersion) + } + if (!existingKeys.has(keys.toVersion)) { + keysToInit.push(keys.toVersion) + } + + if (keysToInit.length > 0) { + await db.initSession(keysToInit) + } + + post({ type: 'sessions', id, fromVersion, toVersion }) + + await Promise.all([ + resolveAndPersistGraph(packageName, fromVersion, controller), + resolveAndPersistGraph(packageName, toVersion, controller), + ]) + + await checkAborted(controller) + + const [session1, session2] = await Promise.all([ + db.getSession(keys.fromVersion), + db.getSession(keys.toVersion), + ]) + + function getKeysForSession(session?: AnalysisSessionEntity) { + if (!session) { + return [] + } + return ignoreOptional + ? [...session.resolvedPackageKeys] + : [...session.resolvedPackageKeys, ...(session.optionalPackageKeys || [])] + } + + const keysV1 = getKeysForSession(session1) + const keysV2 = getKeysForSession(session2) + + const packagesV1 = + keysV1.length > 0 ? await db.packages.where('id').anyOf(keysV1).toArray() : [] + const packagesV2 = + keysV2.length > 0 ? await db.packages.where('id').anyOf(keysV2).toArray() : [] + + const optionalKeysV1 = new Set(session1?.optionalPackageKeys || []) + const optionalKeysV2 = new Set(session2?.optionalPackageKeys || []) + + const mapV1 = buildFlatMap(packagesV1, optionalKeysV1) + const mapV2 = buildFlatMap(packagesV2, optionalKeysV2) + + const result: DiffResult[] = [] + const allPackageNames = new Set([...mapV1.keys(), ...mapV2.keys()]) + + for (const name of allPackageNames) { + const p1 = mapV1.get(name) + const p2 = mapV2.get(name) + + if (!p1 && p2) { + result.push({ + name, + status: 'added', + v1: null, + v2: p2, + sizeDelta: p2.size, + isOptional: p2.isOptional, + }) + } else if (p1 && !p2) { + result.push({ + name, + status: 'removed', + v1: p1, + v2: null, + sizeDelta: -p1.size, + isOptional: p1.isOptional, + }) + } + // Version jumps (changed) and no-change entries are ignored + } + + result.sort((a, b) => Math.abs(b.sizeDelta) - Math.abs(a.sizeDelta)) + + const sizeV1 = session1 + ? session1.totalSize + (ignoreOptional ? 0 : session1.totalOptionalSize || 0) + : 0 + const sizeV2 = session2 + ? session2.totalSize + (ignoreOptional ? 0 : session2.totalOptionalSize || 0) + : 0 + const trueSizeDelta = sizeV2 - sizeV1 + + const mandatorySizeV1 = session1?.totalSize || 0 + const mandatorySizeV2 = session2?.totalSize || 0 + const mandatorySizeDelta = mandatorySizeV2 - mandatorySizeV1 + + let addedCount = 0 + let removedCount = 0 + for (const { status } of result) { + if (status === 'added') { + addedCount++ + } + if (status === 'removed') { + removedCount++ + } + } + + post({ + type: 'result', + id, + result, + summary: { + sizeDelta: trueSizeDelta, + mandatorySizeDelta, + netDependencies: addedCount - removedCount, + added: addedCount, + removed: removedCount, + }, + }) + } catch (error) { + if (error instanceof AbortedError || controller.signal.aborted) { + post({ type: 'aborted', id }) + } else { + post({ type: 'error', id, message: error instanceof Error ? error.message : String(error) }) + } + } finally { + if (abortController === controller) { + abortController = undefined + inFlightId = undefined + } + } +}) + +function buildFlatMap(packages: PackageEntity[], optionalKeys: Set) { + const map = new Map() + + for (const pkg of packages) { + const isOptional = optionalKeys.has(pkg.id) + + if (map.has(pkg.name)) { + const existing = map.get(pkg.name)! + existing.version = `${existing.version} + ${pkg.version}` + existing.size += pkg.unpackedSize + + existing.isOptional = existing.isOptional && isOptional + } else { + map.set(pkg.name, { version: pkg.version, size: pkg.unpackedSize, isOptional }) + } + } + return map +} diff --git a/app/utils/pkg-size/check-aborted.ts b/app/utils/pkg-size/check-aborted.ts new file mode 100644 index 0000000000..4ab8f6b95c --- /dev/null +++ b/app/utils/pkg-size/check-aborted.ts @@ -0,0 +1,13 @@ +export class AbortedError extends Error {} + +export function checkAborted(abortController: AbortController): Promise { + return new Promise((resolve, reject) => + setTimeout(() => { + if (abortController.signal.aborted) { + reject(new AbortedError('pkg-size-aborted')) + } else { + resolve() + } + }, 0), + ) +} diff --git a/app/utils/pkg-size/db.ts b/app/utils/pkg-size/db.ts new file mode 100644 index 0000000000..c5251b4a88 --- /dev/null +++ b/app/utils/pkg-size/db.ts @@ -0,0 +1,163 @@ +import type { + AnalysisSessionEntity, + DependencyEdgeEntity, + PackageData, + PackageEntity, +} from '~/utils/pkg-size/types' +import Dexie from 'dexie' + +const MAX_SESSIONS = 25 + +class NpmxPkgSizeDB extends Dexie { + packages!: Dexie.Table + edges!: Dexie.Table + sessions!: Dexie.Table + + constructor() { + super('NpmxPkgSizeDB', { + autoOpen: false, + }) + + this.version(1).stores({ + packages: 'id, name', + edges: '[parentKey+childName], parentKey, resolvedVersionKey', + sessions: 'rootKey, timestamp, isFinished', + }) + + this.packages = this.table('packages') + this.edges = this.table('edges') + this.sessions = this.table('sessions') + } + + /** + * Helper to evict old sessions and keep the cache size bounded. + */ + private async cleanup(): Promise { + if (!this.isOpen()) { + await this.open() + } + + const finishedSessions = await this.sessions.where('isFinished').equals(1).sortBy('timestamp') + + const count = finishedSessions.length + if (count > MAX_SESSIONS) { + const excessCount = count - MAX_SESSIONS + const sessionsToDelete = finishedSessions.slice(0, excessCount) + const keysToDelete = sessionsToDelete.map(s => s.rootKey) + + await this.sessions.bulkDelete(keysToDelete) + } + } + + async dropDatabase(): Promise { + if (this.isOpen()) { + this.close() + } + await this.delete() + await this.open() + } + + async getSession(id: string): Promise { + if (!this.isOpen()) { + await this.open() + } + + return await this.sessions.where('rootKey').equals(id).first() + } + + async getSessions(ids: string[]): Promise { + if (!this.isOpen()) { + await this.open() + } + + return await this.sessions.where('rootKey').anyOf(ids).toArray() + } + + async initSession(rootKey: string | string[]): Promise { + // Run cleanup before creating new sessions + await this.cleanup() + + if (!this.isOpen()) { + await this.open() + } + + await this.transaction('rw', this.sessions, async () => { + if (Array.isArray(rootKey)) { + await this.sessions.bulkPut( + rootKey.map(key => ({ + rootKey: key, + timestamp: Date.now(), + resolvedPackageKeys: [], + optionalPackageKeys: [], + totalSize: 0, + totalOptionalSize: 0, + isFinished: false, + })), + ) + } else { + await this.sessions.put({ + rootKey, + timestamp: Date.now(), + resolvedPackageKeys: [], + optionalPackageKeys: [], + totalSize: 0, + totalOptionalSize: 0, + isFinished: false, + }) + } + }) + } + + async updateSession( + rootKey: string, + resolvedPackageKeys: string[], + optionalPackageKeys: string[], + totalSize: number, + totalOptionalSize: number, + isFinished: boolean, + ): Promise { + if (!this.isOpen()) { + await this.open() + } + await this.transaction('rw', this.sessions, async () => { + await this.sessions.update(rootKey, { + resolvedPackageKeys, + optionalPackageKeys, + totalSize, + totalOptionalSize, + isFinished, + // Bump timestamp to mark it as recently used + timestamp: Date.now(), + }) + }) + } + + async upsertPackage(pkgKey: string, pkgData: PackageData) { + await this.packages.put({ + id: pkgKey, + name: pkgData.name, + version: pkgData.version, + unpackedSize: pkgData.dist?.unpackedSize || 0, + tarball: pkgData.dist?.tarball || '', + }) + } + + async addDependencyEdge( + parentKey: string, + childName: string, + resolvedVersionKey: string, + childRange: string, + isOptional: boolean, + ) { + // Uses put() with the new composite PK to automatically deduplicate edges + await this.edges.put({ + parentKey, + childName, + childRange, + resolvedVersionKey, + isOptional, + }) + } +} + +export const db = new NpmxPkgSizeDB() diff --git a/app/utils/pkg-size/resolve-and-persist-graph.ts b/app/utils/pkg-size/resolve-and-persist-graph.ts new file mode 100644 index 0000000000..95f06b444f --- /dev/null +++ b/app/utils/pkg-size/resolve-and-persist-graph.ts @@ -0,0 +1,233 @@ +import type { NpmPackageMetadata, PackageData } from '~/utils/pkg-size/types' +import { findMaxSatisfying } from 'verkit' +import { db } from '~/utils/pkg-size/db' +import { checkAborted } from '~/utils/pkg-size/check-aborted' + +const REGISTRY_URL = 'https://registry.npmjs.org' +const TARGET_OS = 'linux' +const TARGET_CPU = 'x64' +const TARGET_LIBC = 'glibc' + +function resolveVersion(metadata: NpmPackageMetadata, rangeOrVersion: string): string | undefined { + const versions = Object.keys(metadata.versions) + if (!rangeOrVersion || rangeOrVersion === 'latest' || rangeOrVersion === '*') { + return metadata['dist-tags'].latest || versions[versions.length - 1] + } + if (metadata['dist-tags'][rangeOrVersion]) return metadata['dist-tags'][rangeOrVersion] + if (metadata.versions[rangeOrVersion]) return rangeOrVersion + return findMaxSatisfying(versions, rangeOrVersion) || metadata['dist-tags'].latest || undefined +} + +function isCompatiblePlatform(pkg: PackageData): boolean { + let isOsCompatible = true + if (pkg.os && pkg.os.length > 0) { + isOsCompatible = + pkg.os.includes(TARGET_OS) || + pkg.os.includes('any') || + (!pkg.os.includes(`!${TARGET_OS}`) && !pkg.os.some(o => !o.startsWith('!'))) + } + + let isCpuCompatible = true + if (pkg.cpu && pkg.cpu.length > 0) { + isCpuCompatible = + pkg.cpu.includes(TARGET_CPU) || + pkg.cpu.includes('any') || + (!pkg.cpu.includes(`!${TARGET_CPU}`) && !pkg.cpu.some(c => !c.startsWith('!'))) + } + + let isLibcCompatible = true + if (pkg.libc && pkg.libc.length > 0) { + isLibcCompatible = + pkg.libc.includes(TARGET_LIBC) || + pkg.libc.includes('any') || + (!pkg.libc.includes(`!${TARGET_LIBC}`) && !pkg.libc.some(c => !c.startsWith('!'))) + } + + return isOsCompatible && isCpuCompatible && isLibcCompatible +} + +async function fetchPackageMetadata( + packageName: string, + abortController: AbortController, +): Promise { + const encodedName = packageName.startsWith('@') + ? `@${encodeURIComponent(packageName.slice(1))}` + : encodeURIComponent(packageName) + + const response = await fetch(`${REGISTRY_URL}/${encodedName}`, { + headers: { Accept: 'application/vnd.npm.install-v1+json' }, + signal: abortController.signal, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch metadata for package ${packageName}: ${response.statusText}`) + } + return response.json() +} + +async function fetchAndResolve( + name: string, + range: string, + abortController: AbortController, +): Promise { + try { + const metadata = await fetchPackageMetadata(name, abortController) + const versionKey = resolveVersion(metadata, range) + + if (!versionKey) { + // oxlint-disable-next-line no-console + console.warn(`[Warning] Could not resolve version for range "${range}" in package "${name}"`) + return undefined + } + + const pkgData = metadata.versions[versionKey] + if (!pkgData || !isCompatiblePlatform(pkgData)) { + return undefined + } + + return pkgData + } catch (error) { + if (!abortController.signal.aborted) { + // oxlint-disable-next-line no-console + console.error(`[Error] Failed resolving ${name}@${range}:`, error) + } + return undefined + } +} + +/** + * Helper 1: Processes the BFS queue for dependencies and performs batch requests. + */ +async function processBfsQueue( + packageName: string, + targetVersion: string, + abortController: AbortController, +) { + const memoryGraph = new Map() + const packageSizes = new Map() + const visited = new Set() + + const queue: { + name: string + range: string + parentKey?: string + isOptional: boolean + }[] = [{ name: packageName, range: targetVersion, isOptional: false }] + + while (queue.length > 0) { + await checkAborted(abortController) + + const batch = queue.splice(0, 5) + + const results = await Promise.all( + batch.map(async item => { + const pkgData = await fetchAndResolve(item.name, item.range, abortController) + return { item, pkgData } + }), + ) + + for (const { item, pkgData } of results) { + if (!pkgData) continue + + const pkgKey = `${pkgData.name}@${pkgData.version}` + + if (!visited.has(pkgKey)) { + visited.add(pkgKey) + packageSizes.set(pkgKey, pkgData.dist?.unpackedSize || 0) + + await db.upsertPackage(pkgKey, pkgData) + + for (const [depName, depRange] of Object.entries(pkgData.dependencies || {})) { + queue.push({ name: depName, range: depRange, parentKey: pkgKey, isOptional: false }) + } + for (const [depName, depRange] of Object.entries(pkgData.optionalDependencies || {})) { + queue.push({ name: depName, range: depRange, parentKey: pkgKey, isOptional: true }) + } + } + + if (item.parentKey) { + await db.addDependencyEdge( + item.parentKey, + pkgData.name, + pkgKey, + item.range, + item.isOptional, + ) + + if (!item.isOptional) { + if (!memoryGraph.has(item.parentKey)) { + memoryGraph.set(item.parentKey, []) + } + memoryGraph.get(item.parentKey)!.push(pkgKey) + } + } + } + } + + return { memoryGraph, packageSizes } +} + +/** + * Helper 2: Computes mandatory dependencies via graph traversal. + */ +function computeMandatoryKeys(rootKey: string, memoryGraph: Map): Set { + const mandatoryKeys = new Set() + const traverse = (key: string) => { + if (mandatoryKeys.has(key)) return + mandatoryKeys.add(key) + const normalDeps = memoryGraph.get(key) || [] + normalDeps.forEach(traverse) + } + traverse(rootKey) + return mandatoryKeys +} + +export async function resolveAndPersistGraph( + packageName: string, + targetVersion: string, + abortController: AbortController, +): Promise { + const rootKey = `${packageName}@${targetVersion}` + + const existingSession = await db.getSession(rootKey) + if (existingSession?.isFinished) { + return rootKey + } + + await db.initSession(rootKey) + + // Coordinate via specialized helpers to satisfy linter/CodeRabbit requirements + const { memoryGraph, packageSizes } = await processBfsQueue( + packageName, + targetVersion, + abortController, + ) + + const mandatoryKeys = computeMandatoryKeys(rootKey, memoryGraph) + + const resolvedPackageKeys: string[] = [] + const optionalPackageKeys: string[] = [] + let totalSize = 0 + let totalOptionalSize = 0 + + for (const [key, size] of packageSizes.entries()) { + if (mandatoryKeys.has(key)) { + resolvedPackageKeys.push(key) + totalSize += size + } else { + optionalPackageKeys.push(key) + totalOptionalSize += size + } + } + + await db.updateSession( + rootKey, + resolvedPackageKeys, + optionalPackageKeys, + totalSize, + totalOptionalSize, + true, + ) + + return rootKey +} diff --git a/app/utils/pkg-size/types.ts b/app/utils/pkg-size/types.ts new file mode 100644 index 0000000000..3e949e3cbb --- /dev/null +++ b/app/utils/pkg-size/types.ts @@ -0,0 +1,113 @@ +export interface PackageEntity { + id: string // "lodash@4.17.21" + name: string // "lodash" + version: string // "4.17.21" + unpackedSize: number + tarball: string +} + +export interface DependencyEdgeEntity { + id?: number // auto-increment + parentKey: string // "nuxt@4.5.0" + childName: string // "lodash" + childRange: string // "^4.17.0" + resolvedVersionKey?: string // "lodash@4.17.21" (cached once semver resolved) + isOptional: boolean +} + +export interface AnalysisSessionEntity { + rootKey: string // "nuxt@4.5.0" + timestamp: number + resolvedPackageKeys: string[] // Flat list of all unique dependencies in the tree + optionalPackageKeys: string[] // Flat list of all unique optional dependencies in the tree + totalSize: number + isFinished: boolean + totalOptionalSize: number +} + +// Root package response with all its versions +export interface NpmPackageMetadata { + 'name': string + 'dist-tags': Record + 'versions': Record +} + +export interface PackageData { + name: string + version: string + // Platform restrictions (crucial to avoid summing native binaries that are not touched) + os?: string[] + cpu?: string[] + libc?: string[] + // distribution data + dist: { + tarball: string + unpackedSize?: number + shasum?: string + integrity?: string + } + // Different types of dependencies + dependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record + devDependencies?: Record +} + +export interface DiffResult { + name: string + isOptional: boolean + status: 'added' | 'removed' | 'changed' | 'unchanged' + v1: { version: string; size: number } | null + v2: { version: string; size: number } | null + sizeDelta: number +} + +export interface UIDiffResult extends Omit { + v1: { version: string; size: number; sizeText: string } | null + v2: { version: string; size: number; sizeText: string } | null + statusText: string + sizeDeltaText: string +} + +interface Summary { + sizeDelta: number + mandatorySizeDelta: number + netDependencies: number + added: number + removed: number +} + +export interface UISummary extends Summary { + // B/KB/MB + sizeDeltaText: string + // bytes + sizeDeltaBytesText: string + // B/KB/MB + mandatorySizeDeltaText: string + // bytes + mandatorySizeDeltaBytesText: string + netDependenciesText: string + addedText: string + removedText: string +} + +export type AnalyzeCauseWorkerRequest = + | { + type: 'analyze-cause' + id: number | string + packageName: string + fromVersion: string + toVersion: string + ignoreOptional?: boolean + } + | { + type: 'analyze-cause-abort' + id: number | string + } + +export type AnalyzeWorkerResponse = + | { type: 'sessions'; id: number | string; fromVersion: string; toVersion: string } + | { type: 'result'; id: number | string; result: DiffResult[]; summary: Summary } + | { type: 'error'; id: number | string; message: string } + | { type: 'aborting'; id: number | string } + | { type: 'aborted'; id: number | string } diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 4f68ff0cb4..73373844e6 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -429,7 +429,32 @@ "title_deps": "Significant dependency count increase since v{version}", "title_both": "Significant size and dependency increase since v{version}", "size": "Install size increased by {percent} ({size} larger)", - "deps": "{count} more dependency | {count} more dependencies" + "deps": "{count} more dependency | {count} more dependencies", + "analyze": { + "analyze": "Analyze", + "cancel": "Cancel", + "cancelling": "Cancelling...", + "error": "Analysis failed, check the console for details.", + "summary": { + "total_size": "Size variation (total): {size} {bytes}", + "js_core_size": "Size variation (only dependencies): {size} {bytes}", + "bytes": "({bytes} bytes)", + "deps_variation": "Dependency variation: {net} ({details})", + "deps_details": "Added: {added}, Removed: {removed}", + "no_deps": "No dependencies found" + }, + "diff": "Diff balance", + "only_deps": "Only dependencies", + "all_deps": "All dependencies", + "expand": "Expand", + "collapse": "Collapse", + "status": { + "added": "Added", + "changed": "Changed", + "unchanged": "Unchanged", + "removed": "Removed" + } + } }, "size_decrease": { "title_size": "Package size decreased since v{version}!", diff --git a/i18n/locales/es.json b/i18n/locales/es.json index b6b545404e..3ea9040f83 100644 --- a/i18n/locales/es.json +++ b/i18n/locales/es.json @@ -421,7 +421,32 @@ "title_deps": "Aumento significativo del número de dependencias desde v{version}", "title_both": "Aumento significativo del tamaño y de las dependencias desde v{version}", "size": "El tamaño de instalación aumentó un {percent} ({size} más grande)", - "deps": "{count} dependencias más" + "deps": "{count} dependencias más", + "analyze": { + "analyze": "Analizar", + "cancel": "Cancelar", + "cancelling": "Cancelando...", + "error": "El análisis falló, revisa la consola para más detalles.", + "summary": { + "total_size": "Variación de tamaño (total): {size} {bytes}", + "js_core_size": "Variación de tamaño (solo dependencias): {size} {bytes}", + "bytes": "({bytes} bytes)", + "deps_variation": "Variación de dependencias: {net} ({details})", + "deps_details": "Añadidas: {added}, Eliminadas: {removed}", + "no_deps": "No se encontraron dependencias" + }, + "diff": "Balance de diferencias", + "only_deps": "Solo dependencias", + "all_deps": "Todas las dependencias", + "expand": "Expandir", + "collapse": "Contraer", + "status": { + "added": "Añadida", + "changed": "Modificada", + "unchanged": "Sin cambios", + "removed": "Eliminada" + } + } }, "size_decrease": { "title_size": "¡El tamaño del paquete ha disminuido desde v{version}!", diff --git a/i18n/schema.json b/i18n/schema.json index 35882a02e2..8fc6477d8a 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1293,6 +1293,81 @@ }, "deps": { "type": "string" + }, + "analyze": { + "type": "object", + "properties": { + "analyze": { + "type": "string" + }, + "cancel": { + "type": "string" + }, + "cancelling": { + "type": "string" + }, + "error": { + "type": "string" + }, + "summary": { + "type": "object", + "properties": { + "total_size": { + "type": "string" + }, + "js_core_size": { + "type": "string" + }, + "bytes": { + "type": "string" + }, + "deps_variation": { + "type": "string" + }, + "deps_details": { + "type": "string" + }, + "no_deps": { + "type": "string" + } + }, + "additionalProperties": false + }, + "diff": { + "type": "string" + }, + "only_deps": { + "type": "string" + }, + "all_deps": { + "type": "string" + }, + "expand": { + "type": "string" + }, + "collapse": { + "type": "string" + }, + "status": { + "type": "object", + "properties": { + "added": { + "type": "string" + }, + "changed": { + "type": "string" + }, + "unchanged": { + "type": "string" + }, + "removed": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/package.json b/package.json index 4605f274c7..e98ac8ecde 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@vueuse/shared": "14.3.0", "algoliasearch": "5.55.1", "defu": "6.1.7", + "dexie": "^4.4.4", "diff": "^9.0.0", "fast-npm-meta": "2.1.0", "focus-trap": "^8.0.0", @@ -135,6 +136,7 @@ "chromatic": "18.0.1", "devalue": "5.8.1", "eslint-plugin-regexp": "3.1.1", + "fake-indexeddb": "^6.2.5", "fast-check": "4.8.0", "h3": "1.15.11", "h3-next": "npm:h3@2.0.1-rc.22", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 254977758f..7cdce54e45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: defu: specifier: 6.1.7 version: 6.1.7 + dexie: + specifier: ^4.4.4 + version: 4.4.4 diff: specifier: ^9.0.0 version: 9.0.0 @@ -331,6 +334,9 @@ importers: eslint-plugin-regexp: specifier: 3.1.1 version: 3.1.1(eslint@10.6.0) + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 fast-check: specifier: 4.8.0 version: 4.8.0 @@ -6500,6 +6506,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dexie@4.4.4: + resolution: {integrity: sha512-jIwsYI8Os2hgnqc6O49YwFDKGc5v5QjGx0wPVp543ip1F53VFAKMLthV2pQosQcVTv3eAskTWYspOx195PM0FQ==} + diff3@0.0.3: resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==} @@ -10658,6 +10667,9 @@ packages: vue-component-type-helpers@3.3.7: resolution: {integrity: sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==} + vue-component-type-helpers@3.3.8: + resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==} + vue-data-ui@3.22.13: resolution: {integrity: sha512-NQeLKNUZQWw9DGQUEQPQo2kIcKV+Uap685DTUzydK6b+N4E6CByy/VepaNIScRbyUV1wFWe5aG3pnyyg32aN7g==} peerDependencies: @@ -15121,7 +15133,7 @@ snapshots: storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react@19.2.7)(vite-plus@0.2.2) type-fest: 2.19.0 vue: 3.5.39(typescript@6.0.3) - vue-component-type-helpers: 3.3.7 + vue-component-type-helpers: 3.3.8 '@swc/helpers@0.5.23': dependencies: @@ -17259,6 +17271,8 @@ snapshots: dependencies: dequal: 2.0.3 + dexie@4.4.4: {} + diff3@0.0.3: {} diff@8.0.4: {} @@ -22872,6 +22886,8 @@ snapshots: vue-component-type-helpers@3.3.7: {} + vue-component-type-helpers@3.3.8: {} + vue-data-ui@3.22.13(vue@3.5.39): dependencies: vue: 3.5.39(typescript@6.0.3) diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index f0791c7990..e0e043b6d7 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -134,6 +134,51 @@ vi.mock('~/composables/useCanGoBack', () => { } }) +vi.mock('~/composables/pkg-size/useAnalyzeCauseWorker', () => ({ + useAnalyzeCauseWorker: () => ({ + available: ref(true), + analyzing: ref(false), + cancelling: ref(false), + loading: ref(false), + result: ref([ + { + isOptional: false, + name: 'valibot', + sizeDelta: 1843461, + sizeDeltaText: '1.8 MB', + status: 'added', + statusText: 'Added', + v1: null, + v2: { + isOptional: false, + size: 1843461, + sizeText: '1.8 MB', + version: '1.4.2', + }, + }, + ]), + error: ref(null), + summary: ref({ + added: 20, + addedText: '20', + mandatorySizeDelta: 13357056, + mandatorySizeDeltaBytesText: '13,357,056', + mandatorySizeDeltaText: '13.4 MB', + netDependencies: 14, + netDependenciesText: '+14', + removed: 6, + removedText: '6', + sizeDelta: 94035365, + sizeDeltaBytesText: '94,035,365', + sizeDeltaText: '94 MB', + }), + noResultScroll: ref(false), + allDependencies: ref(true), + startAnalyzeCause: vi.fn(), + cancelAnalyzeCause: vi.fn(), + }), +})) + // Import components from #components where possible // For server/client variants, we need to import directly to test the specific variant import { @@ -277,6 +322,7 @@ import { TabList, TabItem, TabPanel, + PackageSizeIncreaseAnalysis, } from '#components' // Server variant components must be imported directly to test the server-side render @@ -4573,6 +4619,23 @@ describe('component accessibility audits', () => { expect(results.violations).toEqual([]) }) }) + + describe('SizeIncreaseAnalysis', () => { + it('should have no accessibility violations', async () => { + const component = await mountSuspended(PackageSizeIncreaseAnalysis, { + props: { + packageName: 'nuxt', + version: '4.5.0', + comparisonVersion: '4.4.8', + open: true, + }, + }) + + const results = await runAxe(component) + + expect(results.violations).toEqual([]) + }) + }) }) function applyTheme(colorMode: string, bgTheme: string | null) { diff --git a/test/nuxt/composables/use-analyze-cause-worker.spec.ts b/test/nuxt/composables/use-analyze-cause-worker.spec.ts new file mode 100644 index 0000000000..6448e538c1 --- /dev/null +++ b/test/nuxt/composables/use-analyze-cause-worker.spec.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { computed, defineComponent } from 'vue' +import { mount } from '@vue/test-utils' +import { until } from '@vueuse/core' +import { useAnalyzeCauseWorker } from '../../../app/composables/pkg-size/useAnalyzeCauseWorker' + +vi.mock('~/composables/useNumberFormatter', () => ({ + useBytesFormatter: () => ({ + format: (v: number) => `${v} B`, + t: (key: string) => key, + }), + useNumberFormatter: () => + computed(() => ({ + format: (v: number) => `${v}`, + })), +})) + +const mockWorker = vi.hoisted(() => ({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + postMessage: vi.fn(), +})) + +vi.mock('../../../app/utils/pkg-size/analyze-cause-client-worker', () => ({ + worker: mockWorker, +})) + +function mountComposable(pkg = 'test-pkg', vTo = '2.0.0', vFrom = '1.0.0') { + let composableResult: ReturnType | undefined + + const TestComponent = defineComponent({ + setup() { + const packageName = computed(() => pkg) + const version = computed(() => vTo) + const comparedVersion = computed(() => vFrom) + + composableResult = useAnalyzeCauseWorker(packageName, version, comparedVersion) + return () => {} + }, + }) + + const wrapper = mount(TestComponent) + return { wrapper, result: composableResult! } +} + +describe('useAnalyzeCauseWorker', () => { + function getWorkerMessageHandler() { + const call = mockWorker.addEventListener.mock.calls.find(c => c[0] === 'message') + if (!call || typeof call[1] !== 'function') { + throw new Error('Worker message handler not registered') + } + return call[1] as (event: unknown) => Promise + } + + function getWorkerPostMessageId() { + const call = mockWorker.postMessage.mock.calls[0] + if (!call || !call[0] || typeof call[0] !== 'object' || !('id' in call[0])) { + throw new Error('Worker postMessage not called with expected payload') + } + return (call[0] as { id: string | number }).id + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should initialize and load the worker correctly', async () => { + const { result } = mountComposable() + + await until(result.loading).toBe(false, { timeout: 2000 }) + + expect(result.loading.value).toBe(false) + expect(result.available.value).toBe(true) + expect(mockWorker.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)) + }) + + it('should start analysis and send postMessage', async () => { + const { result } = mountComposable() + await until(result.loading).toBe(false, { timeout: 2000 }) + + vi.useFakeTimers() + + const startPromise = result.startAnalyzeCause() + await vi.runAllTimersAsync() + await startPromise + + expect(result.analyzing.value).toBe(true) + expect(mockWorker.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'analyze-cause', + packageName: 'test-pkg', + }), + ) + }) + + it('should cancel analysis', async () => { + const { result } = mountComposable() + await until(result.loading).toBe(false, { timeout: 2000 }) + + vi.useFakeTimers() + + const startPromise = result.startAnalyzeCause() + await vi.runAllTimersAsync() + await startPromise + + const cancelPromise = result.cancelAnalyzeCause() + await vi.runAllTimersAsync() + await cancelPromise + + expect(result.cancelling.value).toBe(true) + expect(mockWorker.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'analyze-cause-abort', + }), + ) + }) + + it('should handle successful result messages', async () => { + const { result } = mountComposable() + await until(result.loading).toBe(false, { timeout: 2000 }) + + vi.useFakeTimers() + + const messageHandler = getWorkerMessageHandler() + + const startPromise = result.startAnalyzeCause() + await vi.runAllTimersAsync() + await startPromise + + const currentId = getWorkerPostMessageId() + + const handlerPromise = messageHandler({ + data: { + id: currentId, + type: 'result', + result: [ + { name: 'dep-a', status: 'added', sizeDelta: 100, isOptional: false, v1: null, v2: null }, + ], + summary: { + sizeDelta: 100, + mandatorySizeDelta: 100, + netDependencies: 1, + added: 1, + removed: 0, + }, + }, + }) + await vi.runAllTimersAsync() + await handlerPromise + + expect(result.analyzing.value).toBe(false) + expect(result.result.value).toHaveLength(1) + expect(result.summary.value?.sizeDeltaText).toBe('100 B') + }) + + it('should handle worker error messages', async () => { + const { result } = mountComposable() + await until(result.loading).toBe(false, { timeout: 2000 }) + + vi.useFakeTimers() + + const messageHandler = getWorkerMessageHandler() + + const startPromise = result.startAnalyzeCause() + await vi.runAllTimersAsync() + await startPromise + + const currentId = getWorkerPostMessageId() + + await messageHandler({ + data: { id: currentId, type: 'error', message: 'Something exploded' }, + }) + + expect(result.error.value).toBe('Something exploded') + expect(result.analyzing.value).toBe(false) + }) + + it('should clean up listeners on unmount', async () => { + const { wrapper, result } = mountComposable() + await until(result.loading).toBe(false, { timeout: 2000 }) + + wrapper.unmount() + + expect(mockWorker.removeEventListener).toHaveBeenCalledWith('message', expect.any(Function)) + expect(mockWorker.removeEventListener).toHaveBeenCalledWith('error', expect.any(Function)) + }) +}) diff --git a/test/unit/pkg-size-analyze-worker.spec.ts b/test/unit/pkg-size-analyze-worker.spec.ts new file mode 100644 index 0000000000..c0ad31683d --- /dev/null +++ b/test/unit/pkg-size-analyze-worker.spec.ts @@ -0,0 +1,199 @@ +import { vi, describe, it, expect, beforeEach, beforeAll } from 'vitest' + +vi.mock('~/utils/pkg-size/resolve-and-persist-graph', () => ({ + resolveAndPersistGraph: vi.fn().mockResolvedValue('mocked-key'), +})) + +vi.mock('~/utils/pkg-size/db', () => ({ + db: { + getSessions: vi.fn().mockResolvedValue([]), + initSession: vi.fn().mockResolvedValue(undefined), + getSession: vi.fn().mockImplementation(key => ({ + rootKey: key, + resolvedPackageKeys: ['dep-a@1.0.0'], + optionalPackageKeys: [], + totalSize: 1024, + totalOptionalSize: 0, + isFinished: true, + })), + packages: { + where: vi.fn().mockReturnThis(), + anyOf: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue([ + { + id: 'dep-a@1.0.0', + name: 'dep-a', + version: '1.0.0', + unpackedSize: 1024, + isOptional: false, + }, + ]), + }, + }, +})) + +const postMessageMock = vi.fn() +let messageHandler: (event: any) => Promise + +describe('analyze-cause-worker', () => { + beforeAll(async () => { + vi.stubGlobal('self', { + addEventListener: (event: string, handler: any) => { + if (event === 'message') messageHandler = handler + }, + postMessage: postMessageMock, + }) + + await import('../../app/utils/pkg-size/analyze-cause-worker') + }) + + beforeEach(() => { + postMessageMock.mockClear() + vi.clearAllMocks() + }) + + it('should handle analyze-cause-abort correctly', async () => { + await messageHandler({ + data: { type: 'analyze-cause-abort', id: 'test-1' }, + }) + expect(postMessageMock).toHaveBeenCalledWith({ type: 'aborted', id: 'test-1' }) + }) + + it('should process analyze-cause successfully', async () => { + await messageHandler({ + data: { + type: 'analyze-cause', + id: 'test-2', + packageName: 'vue', + fromVersion: '3.4.0', + toVersion: '3.5.0', + }, + }) + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ type: 'sessions', id: 'test-2' }), + ) + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'result', + id: 'test-2', + summary: expect.any(Object), + }), + ) + }) + + it('should ignore unknown message types (Line 34)', async () => { + await messageHandler({ data: { type: 'random-unknown-type' } }) + expect(postMessageMock).not.toHaveBeenCalled() + }) + + it('should handle abort with mismatched id (Lines 25-26)', async () => { + const pending = messageHandler({ + data: { + type: 'analyze-cause', + id: 'active-id', + packageName: 'foo', + fromVersion: '1', + toVersion: '2', + }, + }) + + await messageHandler({ data: { type: 'analyze-cause-abort', id: 'wrong-id' } }) + + expect(postMessageMock).toHaveBeenCalledWith({ type: 'aborted', id: 'wrong-id' }) + + await pending + }) + + it('should handle generic errors in catch block (Lines 169-172)', async () => { + const { resolveAndPersistGraph } = + await import('../../app/utils/pkg-size/resolve-and-persist-graph') + vi.mocked(resolveAndPersistGraph).mockRejectedValueOnce(new Error('Network explosion')) + + await messageHandler({ + data: { + type: 'analyze-cause', + id: 'error-id', + packageName: 'foo', + fromVersion: '1', + toVersion: '2', + }, + }) + + expect(postMessageMock).toHaveBeenCalledWith({ + type: 'error', + id: 'error-id', + message: 'Network explosion', + }) + }) + + it('should handle missing sessions, removed packages, and flatMap merges (Lines 81, 110, 148, 189)', async () => { + const { db } = await import('../../app/utils/pkg-size/db') + + vi.mocked(db.getSession).mockImplementation(async key => { + if (key.includes('1.0.0')) { + return { + rootKey: key, + resolvedPackageKeys: ['dep-a@1.0.0', 'dep-a@1.0.1'], // Diferente versión, mismo paquete para L189 + optionalPackageKeys: [], + totalSize: 2000, + isFinished: true, + } as any + } + return undefined + }) + + vi.mocked(db.packages.toArray).mockResolvedValueOnce([ + { + id: 'dep-a@1.0.0', + name: 'dep-a', + version: '1.0.0', + unpackedSize: 1000, + isOptional: false, + } as any, + { + id: 'dep-a@1.0.1', + name: 'dep-a', + version: '1.0.1', + unpackedSize: 1000, + isOptional: false, + } as any, + ]) + + await messageHandler({ + data: { + type: 'analyze-cause', + id: 'complex-id', + packageName: 'foo', + fromVersion: '1.0.0', + toVersion: '2.0.0', + }, + }) + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'result', + summary: expect.objectContaining({ removed: 1 }), + }), + ) + }) +}) + +describe('analyze-cause-client-worker', () => { + it('should instantiate the Vite worker', async () => { + const WorkerMock = vi.fn() + vi.stubGlobal('Worker', WorkerMock) + + vi.doMock('~/utils/pkg-size/analyze-cause-worker?worker', () => ({ + default: WorkerMock, + })) + + const { worker } = await import('../../app/utils/pkg-size/analyze-cause-client-worker') + + expect(WorkerMock).toHaveBeenCalledWith({ + name: 'NpmxPkgSizeAnalyzeCauseWorker', + }) + expect(worker).toBeInstanceOf(WorkerMock) + }) +}) diff --git a/test/unit/pkg-size-analyze.spec.ts b/test/unit/pkg-size-analyze.spec.ts new file mode 100644 index 0000000000..51ed95cd64 --- /dev/null +++ b/test/unit/pkg-size-analyze.spec.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import 'fake-indexeddb/auto' +import { setupServer } from 'msw/node' +import { http, HttpResponse } from 'msw' +import { resolveAndPersistGraph } from '../../app/utils/pkg-size/resolve-and-persist-graph' +import { db } from '../../app/utils/pkg-size/db' + +const server = setupServer( + http.get('https://registry.npmjs.org/:packageName', ({ params }) => { + const { packageName } = params + + if (packageName === 'test-pkg' || packageName === '@scope/test-pkg') { + return HttpResponse.json({ + 'name': packageName, + 'dist-tags': { latest: '1.0.0' }, + 'versions': { + '1.0.0': { + name: packageName, + version: '1.0.0', + dist: { + unpackedSize: 1024, + tarball: `https://registry.npmjs.org/${packageName}/-/${packageName}-1.0.0.tgz`, + }, + dependencies: { + 'dep-child': '^1.0.0', + }, + optionalDependencies: { + 'opt-child': '^2.0.0', + }, + }, + 'dep-child': { + name: 'dep-child', + version: '1.0.0', + dist: { unpackedSize: 512, tarball: '' }, + }, + 'opt-child': { + name: 'opt-child', + version: '2.0.0', + dist: { unpackedSize: 256, tarball: '' }, + }, + }, + }) + } + + if (packageName === 'dep-child') { + return HttpResponse.json({ + 'name': 'dep-child', + 'dist-tags': { latest: '1.0.0' }, + 'versions': { + '1.0.0': { + name: 'dep-child', + version: '1.0.0', + dist: { unpackedSize: 512, tarball: '' }, + }, + }, + }) + } + + if (packageName === 'opt-child') { + return HttpResponse.json({ + 'name': 'opt-child', + 'dist-tags': { latest: '2.0.0' }, + 'versions': { + '2.0.0': { + name: 'opt-child', + version: '2.0.0', + dist: { unpackedSize: 256, tarball: '' }, + }, + }, + }) + } + + return new HttpResponse(null, { status: 404 }) + }), +) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(async () => { + server.resetHandlers() + await db.dropDatabase() +}) +afterAll(() => server.close()) + +describe('resolveAndPersistGraph', () => { + it('should resolve metadata, process BFS queue, and persist session successfully', async () => { + const controller = new AbortController() + const rootKey = await resolveAndPersistGraph('test-pkg', '1.0.0', controller) + + expect(rootKey).toBe('test-pkg@1.0.0') + + const session = await db.getSession(rootKey) + expect(session).toBeDefined() + expect(session?.isFinished).toBe(true) + expect(session?.totalSize).toBeGreaterThan(0) + + const pkgCount = await db.packages.count() + expect(pkgCount).toBe(3) + + const depChild = await db.packages.get('dep-child@1.0.0') + expect(depChild).toBeDefined() + + const optChild = await db.packages.get('opt-child@2.0.0') + expect(optChild).toBeDefined() + + const edgesCount = await db.edges.count() + expect(edgesCount).toBe(2) + }) + + it('should handle aborted requests gracefully', async () => { + const controller = new AbortController() + controller.abort() + + await expect(resolveAndPersistGraph('test-pkg', '1.0.0', controller)).rejects.toThrow( + 'pkg-size-aborted', + ) + }) +})