Compare commits

...

9 Commits

Author SHA1 Message Date
huang47
a1cf5ad72b test: rerun critical coverage verification 2026-07-09 14:34:53 -07:00
huang47
ab8a25a730 ci: require exact critical coverage baseline 2026-07-09 14:22:12 -07:00
huang47
c5b9d95224 test: verify critical coverage drop 2026-07-09 12:53:39 -07:00
huang47
216878e488 ci: compare critical unit coverage artifacts 2026-07-09 12:38:45 -07:00
huang47
3e2c120e13 ci: publish critical unit coverage baseline 2026-07-09 12:05:22 -07:00
huang47
48be912767 ci: clarify critical unit coverage tolerance 2026-07-09 10:09:46 -07:00
huang47
608de229e1 ci: require exact critical unit coverage 2026-07-09 09:12:06 -07:00
huang47
9e11b49e14 ci: tolerate codecov unit report drift 2026-07-08 20:49:40 -07:00
huang47
427e9a20c4 ci: gate critical unit coverage drops 2026-07-08 17:51:18 -07:00
9 changed files with 642 additions and 783 deletions

View File

@@ -8,6 +8,10 @@ inputs:
head-sha:
description: The commit SHA to find runs for
required: true
event:
description: Optional workflow event to match
required: false
default: ''
not-found-status:
description: Status to output when no run exists
required: false
@@ -33,6 +37,7 @@ runs:
env:
WORKFLOW_ID: ${{ inputs.workflow-id }}
HEAD_SHA: ${{ inputs.head-sha }}
EVENT_NAME: ${{ inputs.event }}
NOT_FOUND_STATUS: ${{ inputs.not-found-status }}
with:
github-token: ${{ inputs.token }}
@@ -42,6 +47,7 @@ runs:
repo: context.repo.repo,
workflow_id: process.env.WORKFLOW_ID,
head_sha: process.env.HEAD_SHA,
event: process.env.EVENT_NAME || undefined,
per_page: 1,
});

View File

@@ -38,6 +38,18 @@ jobs:
- name: Run Vitest tests with coverage
run: pnpm test:coverage
- name: Generate critical unit coverage artifact
run: pnpm coverage:critical:extract
- name: Upload critical unit coverage artifact
if: github.event_name != 'merge_group'
uses: actions/upload-artifact@v6
with:
name: critical-unit-coverage-${{ github.sha }}
path: coverage/critical-unit-coverage.json
retention-days: 30
if-no-files-found: error
- name: Upload unit coverage artifact
if: always() && github.event_name == 'push'
uses: actions/upload-artifact@v6
@@ -55,3 +67,79 @@ jobs:
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
critical-unit-regression:
needs: test
if: ${{ github.event_name == 'pull_request' && needs.test.result == 'success' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download head critical unit coverage
uses: actions/download-artifact@v7
with:
name: critical-unit-coverage-${{ github.sha }}
path: temp/head-critical-coverage
- name: Find base unit coverage run
id: find-base-unit
uses: ./.github/actions/find-workflow-run
with:
workflow-id: ci-tests-unit.yaml
head-sha: ${{ github.event.pull_request.base.sha }}
event: push
token: ${{ secrets.GITHUB_TOKEN }}
- name: Require base unit coverage run
if: steps.find-base-unit.outputs.status != 'ready'
run: |
echo "Critical unit baseline artifact unavailable for base ${{ github.event.pull_request.base.sha }}." >> "$GITHUB_STEP_SUMMARY"
echo "Status: ${{ steps.find-base-unit.outputs.status }}" >> "$GITHUB_STEP_SUMMARY"
exit 1
- name: Download base critical unit coverage
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: critical-unit-coverage-${{ github.event.pull_request.base.sha }}
run_id: ${{ steps.find-base-unit.outputs.run-id }}
path: temp/base-critical-coverage
if_no_artifact_found: warn
- name: Download base unit LCOV fallback
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: unit-coverage
run_id: ${{ steps.find-base-unit.outputs.run-id }}
path: temp/base-unit-coverage
if_no_artifact_found: warn
- name: Prepare base critical unit coverage fallback
run: |
if [ -f temp/base-critical-coverage/critical-unit-coverage.json ]; then
exit 0
fi
if [ ! -f temp/base-unit-coverage/lcov.info ]; then
echo "Critical unit baseline artifact missing for base ${{ github.event.pull_request.base.sha }}." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
pnpm coverage:critical:extract \
--input temp/base-unit-coverage/lcov.info \
--output temp/base-critical-coverage/critical-unit-coverage.json \
--sha=${{ github.event.pull_request.base.sha }}
- name: Compare critical unit coverage
run: >
pnpm coverage:critical:compare
--base temp/base-critical-coverage/critical-unit-coverage.json
--head temp/head-critical-coverage/critical-unit-coverage.json

View File

@@ -19,6 +19,8 @@
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"coverage:critical:compare": "tsx scripts/critical-coverage/compareCriticalCoverage.ts",
"coverage:critical:extract": "tsx scripts/critical-coverage/extractCriticalCoverage.ts",
"dev:cloud": "pnpm dev:cloud:test",
"dev:cloud:test": "cross-env DEV_SERVER_COMFYUI_URL=https://testcloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud:staging": "cross-env DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ vite --config vite.config.mts",

View File

@@ -0,0 +1,120 @@
import { appendFileSync } from 'node:fs'
import {
compareCriticalCoverageReports,
readCriticalCoverageReport
} from './criticalCoverageReport'
import type { CriticalCoverageComparison } from './criticalCoverageReport'
interface Options {
base: string
head: string
}
const options = parseOptions(process.argv.slice(2))
const base = readCriticalCoverageReport(options.base)
const head = readCriticalCoverageReport(options.head)
const comparison = compareCriticalCoverageReports(base, head)
const summary = formatComparison(comparison)
process.stdout.write(`${summary}\n`)
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`)
}
if (comparison.commonBranches === 0) {
process.stderr.write('No comparable critical unit branches found.\n')
process.exit(1)
}
if (comparison.coveredBranchDelta < 0) {
process.stderr.write(
`Critical unit coverage dropped by ${Math.abs(comparison.coveredBranchDelta)} covered branches on the shared branch set.\n`
)
process.exit(1)
}
function parseOptions(args: string[]): Options {
const options: Partial<Options> = {}
for (let i = 0; i < args.length; i++) {
const arg = args[i]
const next = args[i + 1]
if (arg === '--base' && next) {
options.base = next
i++
} else if (arg.startsWith('--base=')) {
options.base = arg.slice('--base='.length)
} else if (arg === '--head' && next) {
options.head = next
i++
} else if (arg.startsWith('--head=')) {
options.head = arg.slice('--head='.length)
}
}
if (!options.base || !options.head) {
throw new Error(
'Usage: compareCriticalCoverage --base <json> --head <json>'
)
}
return {
base: options.base,
head: options.head
}
}
function formatComparison(comparison: CriticalCoverageComparison): string {
const passed = comparison.coveredBranchDelta >= 0
const lines = [
`## Critical Unit Coverage Gate: ${passed ? 'PASS' : 'FAIL'}`,
'',
`Base tested commit: \`${comparison.baseSha}\``,
`PR tested commit: \`${comparison.headSha}\``,
'',
'| Metric | Count |',
'|---|--:|',
`| Comparable critical branches | ${comparison.commonBranches} |`,
`| Covered in base | ${comparison.commonCoveredBranchesInBase} |`,
`| Covered in head | ${comparison.commonCoveredBranchesInHead} |`,
`| Covered branch delta | ${formatSignedCount(comparison.coveredBranchDelta)} |`,
`| Base-only branches | ${comparison.baseOnlyBranches} |`,
`| Head-only branches | ${comparison.headOnlyBranches} |`,
`| Covered-to-uncovered branches | ${comparison.regressions.length} |`,
''
]
if (passed) {
lines.push('PASS: Critical branch coverage did not decrease.')
return lines.join('\n')
}
lines.push('| File | Line | Branch | Base | Head |')
lines.push('|---|--:|---|--:|--:|')
for (const regression of comparison.regressions.slice(0, 25)) {
lines.push(
`| \`${regression.file}\` | ${regression.line} | ${regression.block}:${regression.branch} | ${formatTaken(regression.baseTaken)} | ${formatTaken(regression.headTaken)} |`
)
}
if (comparison.regressions.length > 25) {
lines.push('')
lines.push(
`${comparison.regressions.length - 25} additional regressions omitted from this summary.`
)
}
return lines.join('\n')
}
function formatTaken(value: number | null): string {
return value === null ? '0' : String(value)
}
function formatSignedCount(value: number): string {
return value > 0 ? `+${value}` : String(value)
}

View File

@@ -0,0 +1,45 @@
export const CRITICAL_COVERAGE_DIRS = [
'src/base',
'src/composables',
'src/core',
'src/lib/litegraph/src/node',
'src/lib/litegraph/src/subgraph',
'src/lib/litegraph/src/utils',
'src/platform/assets/composables',
'src/platform/assets/mappings',
'src/platform/assets/schemas',
'src/platform/assets/services',
'src/platform/assets/utils',
'src/platform/errorCatalog',
'src/platform/keybindings',
'src/platform/missingMedia',
'src/platform/missingModel',
'src/platform/navigation',
'src/platform/nodeReplacement',
'src/platform/remote',
'src/platform/remoteConfig',
'src/platform/secrets',
'src/platform/settings',
'src/platform/workflow',
'src/platform/workspace/api',
'src/platform/workspace/auth',
'src/platform/workspace/composables',
'src/platform/workspace/stores',
'src/platform/workspace/utils',
'src/schemas',
'src/scripts',
'src/services',
'src/stores',
'src/utils',
'src/workbench/extensions/manager/composables',
'src/workbench/extensions/manager/services',
'src/workbench/extensions/manager/stores',
'src/workbench/extensions/manager/utils',
'src/workbench/utils'
] as const
export function isCriticalCoveragePath(filePath: string): boolean {
return CRITICAL_COVERAGE_DIRS.some(
(dir) => filePath === dir || filePath.startsWith(`${dir}/`)
)
}

View File

@@ -0,0 +1,322 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, isAbsolute, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
CRITICAL_COVERAGE_DIRS,
isCriticalCoveragePath
} from './criticalCoverageDirs'
export interface CriticalBranchCoverage {
key: string
file: string
line: number
block: string
branch: string
taken: number | null
covered: boolean
}
export interface CriticalCoverageReport {
schemaVersion: 1
source: 'lcov'
sha: string
generatedAt: string
inputPath: string
criticalDirs: readonly string[]
totals: {
files: number
branches: number
coveredBranches: number
}
branches: CriticalBranchCoverage[]
}
export interface CriticalCoverageRegression extends CriticalBranchCoverage {
baseTaken: number | null
headTaken: number | null
}
export interface CriticalCoverageComparison {
baseSha: string
headSha: string
commonBranches: number
baseOnlyBranches: number
headOnlyBranches: number
commonCoveredBranchesInBase: number
commonCoveredBranchesInHead: number
coveredBranchDelta: number
regressions: CriticalCoverageRegression[]
}
interface CreateReportOptions {
inputPath: string
sha: string
generatedAt?: string
cwd?: string
}
export function createCriticalCoverageReport({
inputPath,
sha,
generatedAt = new Date().toISOString(),
cwd = process.cwd()
}: CreateReportOptions): CriticalCoverageReport {
const lcov = readFileSync(inputPath, 'utf-8')
const branches = parseCriticalBranches(lcov, cwd)
const files = new Set(branches.map((branch) => branch.file))
const coveredBranches = branches.filter((branch) => branch.covered).length
return {
schemaVersion: 1,
source: 'lcov',
sha,
generatedAt,
inputPath,
criticalDirs: CRITICAL_COVERAGE_DIRS,
totals: {
files: files.size,
branches: branches.length,
coveredBranches
},
branches
}
}
export function writeCriticalCoverageReport(
report: CriticalCoverageReport,
outputPath: string
): void {
mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`)
}
export function readCriticalCoverageReport(
inputPath: string
): CriticalCoverageReport {
const parsed: unknown = JSON.parse(readFileSync(inputPath, 'utf-8'))
if (!isCriticalCoverageReport(parsed)) {
throw new Error(`Invalid critical coverage report: ${inputPath}`)
}
return parsed
}
export function compareCriticalCoverageReports(
base: CriticalCoverageReport,
head: CriticalCoverageReport
): CriticalCoverageComparison {
const baseBranches = new Map(
base.branches.map((branch) => [branch.key, branch])
)
const headBranches = new Map(
head.branches.map((branch) => [branch.key, branch])
)
const regressions: CriticalCoverageRegression[] = []
let commonBranches = 0
let commonCoveredBranchesInBase = 0
let commonCoveredBranchesInHead = 0
let baseOnlyBranches = 0
for (const [key, baseBranch] of baseBranches) {
const headBranch = headBranches.get(key)
if (!headBranch) {
baseOnlyBranches++
continue
}
commonBranches++
if (baseBranch.covered) {
commonCoveredBranchesInBase++
}
if (headBranch.covered) {
commonCoveredBranchesInHead++
}
if (baseBranch.covered && !headBranch.covered) {
regressions.push({
...baseBranch,
baseTaken: baseBranch.taken,
headTaken: headBranch.taken
})
}
}
return {
baseSha: base.sha,
headSha: head.sha,
commonBranches,
baseOnlyBranches,
headOnlyBranches: [...headBranches.keys()].filter(
(key) => !baseBranches.has(key)
).length,
commonCoveredBranchesInBase,
commonCoveredBranchesInHead,
coveredBranchDelta:
commonCoveredBranchesInHead - commonCoveredBranchesInBase,
regressions: regressions.sort(compareBranches)
}
}
function parseCriticalBranches(
lcov: string,
cwd: string
): CriticalBranchCoverage[] {
let currentFile = ''
const branches = new Map<string, CriticalBranchCoverage>()
for (const line of lcov.split('\n')) {
if (line.startsWith('SF:')) {
currentFile = normalizeCoveragePath(line.slice(3), cwd)
continue
}
if (!line.startsWith('BRDA:') || !isCriticalCoveragePath(currentFile)) {
continue
}
const branch = parseBranchData(currentFile, line.slice(5))
if (!branch) {
continue
}
const existing = branches.get(branch.key)
if (!existing) {
branches.set(branch.key, branch)
continue
}
branches.set(branch.key, mergeBranchCoverage(existing, branch))
}
return [...branches.values()].sort(compareBranches)
}
function parseBranchData(
file: string,
data: string
): CriticalBranchCoverage | null {
const [lineValue, block, branch, takenValue] = data.split(',')
const line = Number(lineValue)
if (
!Number.isInteger(line) ||
!block ||
!branch ||
takenValue === undefined
) {
return null
}
const taken = takenValue === '-' ? null : Number(takenValue)
const covered = taken !== null && Number.isFinite(taken) && taken > 0
const key = `${file}:${line}:${block}:${branch}`
return {
key,
file,
line,
block,
branch,
taken: Number.isFinite(taken) ? taken : null,
covered
}
}
function mergeBranchCoverage(
left: CriticalBranchCoverage,
right: CriticalBranchCoverage
): CriticalBranchCoverage {
const taken =
left.taken === null || right.taken === null
? null
: left.taken + right.taken
return {
...left,
taken,
covered: left.covered || right.covered
}
}
function compareBranches(
left: CriticalBranchCoverage,
right: CriticalBranchCoverage
): number {
return (
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.block.localeCompare(right.block) ||
left.branch.localeCompare(right.branch)
)
}
function normalizeCoveragePath(filePath: string, cwd: string): string {
const decodedPath = filePath.startsWith('file://')
? fileURLToPath(filePath)
: filePath
const relativePath = isAbsolute(decodedPath)
? relative(cwd, decodedPath)
: decodedPath
const normalizedPath = relativePath.replace(/\\/g, '/').replace(/^\.\//, '')
if (!normalizedPath.startsWith('../')) {
return normalizedPath
}
const srcIndex = normalizedPath.indexOf('/src/')
return srcIndex === -1 ? normalizedPath : normalizedPath.slice(srcIndex + 1)
}
function isCriticalCoverageReport(
value: unknown
): value is CriticalCoverageReport {
if (!isRecord(value)) {
return false
}
const totals = value.totals
return (
value.schemaVersion === 1 &&
value.source === 'lcov' &&
typeof value.sha === 'string' &&
typeof value.generatedAt === 'string' &&
typeof value.inputPath === 'string' &&
Array.isArray(value.criticalDirs) &&
isRecord(totals) &&
typeof totals.files === 'number' &&
typeof totals.branches === 'number' &&
typeof totals.coveredBranches === 'number' &&
Array.isArray(value.branches) &&
value.branches.every(isCriticalBranchCoverage)
)
}
function isCriticalBranchCoverage(
value: unknown
): value is CriticalBranchCoverage {
if (!isRecord(value)) {
return false
}
return (
typeof value.key === 'string' &&
typeof value.file === 'string' &&
typeof value.line === 'number' &&
typeof value.block === 'string' &&
typeof value.branch === 'string' &&
(typeof value.taken === 'number' || value.taken === null) &&
typeof value.covered === 'boolean'
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}

View File

@@ -0,0 +1,58 @@
import {
createCriticalCoverageReport,
writeCriticalCoverageReport
} from './criticalCoverageReport'
interface Options {
input: string
output: string
sha: string
}
const options = parseOptions(process.argv.slice(2))
const report = createCriticalCoverageReport({
inputPath: options.input,
sha: options.sha
})
writeCriticalCoverageReport(report, options.output)
process.stdout.write(
[
`Critical coverage branches: ${report.totals.coveredBranches}/${report.totals.branches}`,
`Critical coverage files: ${report.totals.files}`,
`Wrote ${options.output}`
].join('\n') + '\n'
)
function parseOptions(args: string[]): Options {
const options: Options = {
input: 'coverage/lcov.info',
output: 'coverage/critical-unit-coverage.json',
sha: process.env.GITHUB_SHA ?? 'unknown'
}
for (let i = 0; i < args.length; i++) {
const arg = args[i]
const next = args[i + 1]
if (arg === '--input' && next) {
options.input = next
i++
} else if (arg.startsWith('--input=')) {
options.input = arg.slice('--input='.length)
} else if (arg === '--output' && next) {
options.output = next
i++
} else if (arg.startsWith('--output=')) {
options.output = arg.slice('--output='.length)
} else if (arg === '--sha' && next) {
options.sha = next
i++
} else if (arg.startsWith('--sha=')) {
options.sha = arg.slice('--sha='.length)
}
}
return options
}

View File

@@ -1,743 +0,0 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type Load3d from '@/extensions/core/load3d/Load3d'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useLoad3dService } from '@/services/load3dService'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
const { nodeMap, useLoad3dViewerMock, skeletonCloneMock } = vi.hoisted(() => ({
nodeMap: new Map<LGraphNode, Load3d>(),
useLoad3dViewerMock: vi.fn(),
skeletonCloneMock: vi.fn()
}))
vi.mock('@/composables/useLoad3d', () => ({
nodeToLoad3dMap: nodeMap
}))
vi.mock('@/composables/useLoad3dViewer', () => ({
useLoad3dViewer: useLoad3dViewerMock
}))
vi.mock('three/examples/jsm/utils/SkeletonUtils', () => ({
clone: skeletonCloneMock
}))
// Track every node a test creates so the load3dService singleton's
// internal viewerInstances map can be drained in beforeEach without
// reaching into the module's private state.
const createdNodes = new Set<LGraphNode>()
function makeNode(id: number | string): LGraphNode {
const node = createMockLGraphNode({ id })
createdNodes.add(node)
return node
}
function makeLoad3d(): Load3d {
return {
remove: vi.fn()
} as unknown as Load3d
}
function makeViewer(overrides: Record<string, unknown> = {}) {
return {
needApplyChanges: { value: false },
applyChanges: vi.fn().mockResolvedValue(true),
cleanup: vi.fn(),
...overrides
}
}
describe('load3dService', () => {
beforeEach(() => {
vi.clearAllMocks()
nodeMap.clear()
const svc = useLoad3dService()
for (const node of createdNodes) svc.removeViewer(node)
createdNodes.clear()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('singleton', () => {
it('returns the same instance from useLoad3dService()', () => {
expect(useLoad3dService()).toBe(useLoad3dService())
})
})
describe('getLoad3d (sync)', () => {
it('returns null when the load3d module has not been loaded yet', () => {
// Before any async accessor has been called, the cache is empty.
// We can't easily simulate "module never loaded" because vi.mock makes
// it eagerly available, so this test verifies the behavior via missing
// entries instead.
const node = makeNode('missing')
expect(useLoad3dService().getLoad3d(node)).toBeNull()
})
it('returns null after async load when the node has no entry in the map', async () => {
const svc = useLoad3dService()
// Trigger the async loader so the sync path has a populated cache.
await svc.getLoad3dAsync(makeNode('anything'))
expect(svc.getLoad3d(makeNode('still-missing'))).toBeNull()
})
it('returns the registered Load3d instance once the map has been populated', async () => {
const svc = useLoad3dService()
const node = makeNode('a')
const load3d = makeLoad3d()
nodeMap.set(node, load3d)
await svc.getLoad3dAsync(node)
expect(svc.getLoad3d(node)).toBe(load3d)
})
})
describe('getLoad3dAsync', () => {
it('returns the Load3d for a registered node', async () => {
const svc = useLoad3dService()
const node = makeNode('async-a')
const load3d = makeLoad3d()
nodeMap.set(node, load3d)
await expect(svc.getLoad3dAsync(node)).resolves.toBe(load3d)
})
it('returns null for an unregistered node', async () => {
const svc = useLoad3dService()
await expect(
svc.getLoad3dAsync(makeNode('async-missing'))
).resolves.toBeNull()
})
})
describe('getNodeByLoad3d', () => {
it('finds the node owning a given Load3d instance', async () => {
const svc = useLoad3dService()
const node = makeNode('owner')
const load3d = makeLoad3d()
nodeMap.set(node, load3d)
await svc.getLoad3dAsync(node)
expect(svc.getNodeByLoad3d(load3d)).toBe(node)
})
it('returns null when the Load3d instance is not in the map', async () => {
const svc = useLoad3dService()
await svc.getLoad3dAsync(makeNode('warmup'))
expect(svc.getNodeByLoad3d(makeLoad3d())).toBeNull()
})
})
describe('removeLoad3d', () => {
it('calls remove() on the instance and drops it from the map', async () => {
const svc = useLoad3dService()
const node = makeNode('to-remove')
const load3d = makeLoad3d()
nodeMap.set(node, load3d)
await svc.getLoad3dAsync(node)
svc.removeLoad3d(node)
expect(load3d.remove).toHaveBeenCalled()
expect(nodeMap.has(node)).toBe(false)
})
it('is a no-op when the node has no registered Load3d', async () => {
const svc = useLoad3dService()
await svc.getLoad3dAsync(makeNode('warmup'))
expect(() => svc.removeLoad3d(makeNode('not-there'))).not.toThrow()
})
})
describe('clear', () => {
it('removes every registered Load3d', async () => {
const svc = useLoad3dService()
const a = makeNode('a')
const b = makeNode('b')
const ld1 = makeLoad3d()
const ld2 = makeLoad3d()
nodeMap.set(a, ld1)
nodeMap.set(b, ld2)
await svc.getLoad3dAsync(a)
svc.clear()
expect(nodeMap.size).toBe(0)
expect(ld1.remove).toHaveBeenCalled()
expect(ld2.remove).toHaveBeenCalled()
})
})
describe('viewer lifecycle', () => {
it('getOrCreateViewer creates a viewer on first call and reuses it on subsequent calls', async () => {
const svc = useLoad3dService()
const node = makeNode('v1')
const viewer = makeViewer()
useLoad3dViewerMock.mockReturnValue(viewer)
const first = await svc.getOrCreateViewer(node)
const second = await svc.getOrCreateViewer(node)
expect(first).toBe(viewer)
expect(second).toBe(viewer)
expect(useLoad3dViewerMock).toHaveBeenCalledTimes(1)
expect(useLoad3dViewerMock).toHaveBeenCalledWith(node)
})
it('getOrCreateViewerSync uses the supplied factory once and caches the result', () => {
const svc = useLoad3dService()
const node = makeNode('v-sync')
const viewer = makeViewer()
const factory = vi.fn().mockReturnValue(viewer)
const first = svc.getOrCreateViewerSync(
node,
factory as unknown as typeof useLoad3dViewerMock
)
const second = svc.getOrCreateViewerSync(
node,
factory as unknown as typeof useLoad3dViewerMock
)
expect(first).toBe(viewer)
expect(second).toBe(viewer)
expect(factory).toHaveBeenCalledTimes(1)
})
it('removeViewer calls cleanup and forgets the viewer', async () => {
const svc = useLoad3dService()
const node = makeNode('v2')
const viewer = makeViewer()
useLoad3dViewerMock.mockReturnValue(viewer)
await svc.getOrCreateViewer(node)
svc.removeViewer(node)
expect(viewer.cleanup).toHaveBeenCalled()
useLoad3dViewerMock.mockClear()
const fresh = makeViewer()
useLoad3dViewerMock.mockReturnValue(fresh)
const result = await svc.getOrCreateViewer(node)
expect(useLoad3dViewerMock).toHaveBeenCalledTimes(1)
expect(result).toBe(fresh)
})
it('removeViewer is safe when no viewer has been created for the node', () => {
const svc = useLoad3dService()
expect(() => svc.removeViewer(makeNode('never'))).not.toThrow()
})
})
describe('handleViewerClose', () => {
it('removes the viewer without applying changes when none are pending', async () => {
const svc = useLoad3dService()
const node = makeNode('close-clean')
const viewer = makeViewer({ needApplyChanges: { value: false } })
useLoad3dViewerMock.mockReturnValue(viewer)
await svc.getOrCreateViewer(node)
await svc.handleViewerClose(node)
expect(viewer.applyChanges).not.toHaveBeenCalled()
expect(viewer.cleanup).toHaveBeenCalled()
})
it('applies changes and syncs the node config when changes are pending', async () => {
const svc = useLoad3dService()
const syncLoad3dConfig = vi.fn()
const node = Object.assign(makeNode('close-dirty'), {
syncLoad3dConfig
}) as LGraphNode
const viewer = makeViewer({ needApplyChanges: { value: true } })
useLoad3dViewerMock.mockReturnValue(viewer)
await svc.getOrCreateViewer(node)
await svc.handleViewerClose(node)
expect(viewer.applyChanges).toHaveBeenCalled()
expect(syncLoad3dConfig).toHaveBeenCalled()
expect(viewer.cleanup).toHaveBeenCalled()
})
it('skips syncLoad3dConfig when the node does not define it', async () => {
const svc = useLoad3dService()
const node = makeNode('close-no-sync')
const viewer = makeViewer({ needApplyChanges: { value: true } })
useLoad3dViewerMock.mockReturnValue(viewer)
await svc.getOrCreateViewer(node)
await expect(svc.handleViewerClose(node)).resolves.toBeUndefined()
expect(viewer.applyChanges).toHaveBeenCalled()
expect(viewer.cleanup).toHaveBeenCalled()
})
})
describe('handleViewportRefresh', () => {
it('returns silently when the load3d is null', () => {
expect(() => useLoad3dService().handleViewportRefresh(null)).not.toThrow()
})
it('toggles the camera through the opposite type and back, then updates controls', () => {
const controls = { update: vi.fn() }
const load3d = {
handleResize: vi.fn(),
getCurrentCameraType: vi.fn().mockReturnValue('perspective'),
toggleCamera: vi.fn(),
getControlsManager: vi.fn().mockReturnValue({ controls })
} as unknown as Load3d
useLoad3dService().handleViewportRefresh(load3d)
expect(load3d.handleResize).toHaveBeenCalled()
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(1, 'orthographic')
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(2, 'perspective')
expect(controls.update).toHaveBeenCalled()
})
it('toggles in the reverse direction when starting from orthographic', () => {
const controls = { update: vi.fn() }
const load3d = {
handleResize: vi.fn(),
getCurrentCameraType: vi.fn().mockReturnValue('orthographic'),
toggleCamera: vi.fn(),
getControlsManager: vi.fn().mockReturnValue({ controls })
} as unknown as Load3d
useLoad3dService().handleViewportRefresh(load3d)
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(1, 'perspective')
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(2, 'orthographic')
})
})
describe('copyLoad3dState', () => {
type SourceOverrides = Partial<{
currentModel: THREE.Object3D | null
isSplat: boolean
originalURL: string | null
originalModel: unknown
materialMode: string
currentUpDirection: string
appliedTexture: unknown
gizmoEnabled: boolean
hasAnimations: boolean
cameraType: 'perspective' | 'orthographic'
backgroundInfo: { type: 'image' | 'color' }
lightsIntensity: number | undefined
fov: number
}>
function makeSource(overrides: SourceOverrides = {}): Load3d {
const {
currentModel = null,
isSplat = false,
originalURL = null,
originalModel = null,
materialMode = 'original',
currentUpDirection = 'original',
appliedTexture = null,
gizmoEnabled = false,
hasAnimations = false,
cameraType = 'perspective',
backgroundInfo = { type: 'color' },
lightsIntensity = 0.8,
fov = 35
} = overrides
const ambient = { intensity: 0.5 }
const main = { intensity: lightsIntensity }
return {
modelManager: { currentModel, originalURL },
getGizmoManager: () => ({
isEnabled: () => gizmoEnabled,
getInitialTransform: () => ({
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 },
scale: { x: 4, y: 5, z: 6 }
})
}),
isSplatModel: () => isSplat,
getModelManager: () => ({
originalModel,
materialMode,
currentUpDirection,
appliedTexture
}),
getGizmoTransform: () => ({
position: { x: 7, y: 8, z: 9 },
rotation: { x: 0.4, y: 0.5, z: 0.6 },
scale: { x: 10, y: 11, z: 12 }
}),
hasAnimations: () => hasAnimations,
getCurrentCameraType: () => cameraType,
getCameraState: () => ({ snapshot: true }),
getSceneManager: () => ({
scene: new THREE.Scene(),
currentBackgroundColor: '#abcdef',
gridHelper: { visible: true },
getCurrentBackgroundInfo: () => backgroundInfo
}),
getLightingManager: () => ({ lights: [ambient, main] }),
getCameraManager: () => ({ perspectiveCamera: { fov } })
} as unknown as Load3d
}
type TargetState = {
modelManager: {
currentModel: THREE.Object3D | null
originalModel: unknown
materialMode: string
currentUpDirection: string
appliedTexture: unknown
}
gizmoManager: {
isEnabled: () => boolean
detach: ReturnType<typeof vi.fn>
setupForModel: ReturnType<typeof vi.fn>
}
animationManager: {
setupModelAnimations: ReturnType<typeof vi.fn>
}
sceneRemoved: THREE.Object3D[]
sceneAdded: THREE.Object3D[]
}
function makeTarget(
opts: {
gizmoEnabled?: boolean
existingModel?: THREE.Object3D | null
} = {}
) {
const { gizmoEnabled = false, existingModel = null } = opts
const scene = new THREE.Scene()
const sceneRemoved: THREE.Object3D[] = []
const sceneAdded: THREE.Object3D[] = []
const sceneRemove = vi.fn((o: THREE.Object3D) => {
sceneRemoved.push(o)
scene.remove(o)
})
const sceneAdd = vi.fn((o: THREE.Object3D) => {
sceneAdded.push(o)
scene.add(o)
})
const modelManager = {
currentModel: existingModel as THREE.Object3D | null,
originalModel: null as unknown,
materialMode: 'original',
currentUpDirection: 'original',
appliedTexture: null as unknown
}
const animationManager = {
setupModelAnimations: vi.fn()
}
// Memoize the gizmo manager so production code's repeated
// `target.getGizmoManager()` calls reach the same vi.fn instances.
const gizmoManager = {
isEnabled: () => gizmoEnabled,
detach: vi.fn(),
setupForModel: vi.fn()
}
const target = {
getGizmoManager: () => gizmoManager,
getModelManager: () => modelManager,
getSceneManager: () => ({
scene: {
add: sceneAdd,
remove: sceneRemove
} as unknown as THREE.Scene
}),
loadModel: vi.fn().mockResolvedValue(undefined),
setMaterialMode: vi.fn(),
setUpDirection: vi.fn(),
applyGizmoTransform: vi.fn(),
setGizmoEnabled: vi.fn(),
animationManager,
toggleCamera: vi.fn(),
setCameraState: vi.fn(),
setBackgroundColor: vi.fn(),
toggleGrid: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setLightIntensity: vi.fn(),
setFOV: vi.fn()
} as unknown as Load3d
const state: TargetState = {
modelManager,
gizmoManager,
animationManager,
sceneRemoved,
sceneAdded
}
return { target, state }
}
function makeModel(): THREE.Object3D {
return new THREE.Object3D()
}
it('copies camera/scene/lighting/FOV even when there is no source model', async () => {
const source = makeSource({ currentModel: null, lightsIntensity: 2 })
const { target } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.toggleCamera).toHaveBeenCalledWith('perspective')
expect(target.setCameraState).toHaveBeenCalledWith({ snapshot: true })
expect(target.setBackgroundColor).toHaveBeenCalledWith('#abcdef')
expect(target.toggleGrid).toHaveBeenCalledWith(true)
expect(target.setLightIntensity).toHaveBeenCalledWith(2)
expect(target.setFOV).toHaveBeenCalledWith(35)
expect(skeletonCloneMock).not.toHaveBeenCalled()
expect(target.loadModel).not.toHaveBeenCalled()
})
it('uses target.loadModel(originalURL) for splat models, never invoking SkeletonUtils.clone', async () => {
const source = makeSource({
currentModel: makeModel(),
isSplat: true,
originalURL: 'http://example.com/scan.splat'
})
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.loadModel).toHaveBeenCalledWith(
'http://example.com/scan.splat'
)
expect(skeletonCloneMock).not.toHaveBeenCalled()
})
it('skips loadModel for splat models when originalURL is null', async () => {
const source = makeSource({
currentModel: makeModel(),
isSplat: true,
originalURL: null
})
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.loadModel).not.toHaveBeenCalled()
})
it('removes the target existing model from the scene before adding the clone', async () => {
const existing = makeModel()
existing.name = 'existing'
const source = makeSource({ currentModel: makeModel() })
const { target, state } = makeTarget({ existingModel: existing })
const clone = makeModel()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(state.sceneRemoved).toContain(existing)
expect(state.sceneAdded).toContain(clone)
})
it('clones the source model via SkeletonUtils and assigns it as the target current model', async () => {
const sourceModel = makeModel()
const clone = makeModel()
const source = makeSource({ currentModel: sourceModel })
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(skeletonCloneMock).toHaveBeenCalledWith(sourceModel)
expect(state.modelManager.currentModel).toBe(clone)
})
it('copies originalModel, material mode, up direction, and applied texture from source to target', async () => {
const sourceOriginal = { kind: 'gltf' }
const texture = { id: 'tex1' }
const source = makeSource({
currentModel: makeModel(),
originalModel: sourceOriginal,
materialMode: 'wireframe',
currentUpDirection: '+y',
appliedTexture: texture
})
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(state.modelManager.originalModel).toBe(sourceOriginal)
expect(state.modelManager.materialMode).toBe('wireframe')
expect(state.modelManager.currentUpDirection).toBe('+y')
expect(state.modelManager.appliedTexture).toBe(texture)
expect(target.setMaterialMode).toHaveBeenCalledWith('wireframe')
expect(target.setUpDirection).toHaveBeenCalledWith('+y')
})
it('positions the clone at the source initial transform', async () => {
const clone = makeModel()
const source = makeSource({ currentModel: makeModel() })
const { target } = makeTarget()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(clone.position.toArray()).toEqual([1, 2, 3])
expect(clone.rotation.toArray().slice(0, 3)).toEqual([0.1, 0.2, 0.3])
expect(clone.scale.toArray()).toEqual([4, 5, 6])
})
it('applies the source gizmo transform to the target', async () => {
const source = makeSource({ currentModel: makeModel() })
const { target } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.applyGizmoTransform).toHaveBeenCalledWith(
{ x: 7, y: 8, z: 9 },
{ x: 0.4, y: 0.5, z: 0.6 },
{ x: 10, y: 11, z: 12 }
)
})
it('enables the gizmo on target when the source had it enabled', async () => {
const source = makeSource({
currentModel: makeModel(),
gizmoEnabled: true
})
const { target } = makeTarget({ gizmoEnabled: false })
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setGizmoEnabled).toHaveBeenCalledWith(true)
})
it('enables the gizmo on target when the target previously had it enabled, even if source did not', async () => {
const source = makeSource({
currentModel: makeModel(),
gizmoEnabled: false
})
const { target } = makeTarget({ gizmoEnabled: true })
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setGizmoEnabled).toHaveBeenCalledWith(true)
})
it('does not enable the gizmo when neither side had it', async () => {
const source = makeSource({
currentModel: makeModel(),
gizmoEnabled: false
})
const { target } = makeTarget({ gizmoEnabled: false })
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setGizmoEnabled).not.toHaveBeenCalled()
})
it('forwards animation setup when the source has animations', async () => {
const sourceOriginal = { kind: 'gltf' }
const clone = makeModel()
const source = makeSource({
currentModel: makeModel(),
originalModel: sourceOriginal,
hasAnimations: true
})
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(state.animationManager.setupModelAnimations).toHaveBeenCalledWith(
clone,
sourceOriginal
)
})
it('does not forward animation setup when the source has none', async () => {
const source = makeSource({
currentModel: makeModel(),
hasAnimations: false
})
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(state.animationManager.setupModelAnimations).not.toHaveBeenCalled()
})
it('forwards an image background to setBackgroundImage when the source node has a configured path', async () => {
const node = createMockLGraphNode({
id: 'bg-source',
properties: { 'Scene Config': { backgroundImage: '3d/bg.png' } }
})
createdNodes.add(node)
const source = makeSource({ backgroundInfo: { type: 'image' } })
nodeMap.set(node, source)
// Warm the cache so `getNodeByLoad3d` finds the source.
await useLoad3dService().getLoad3dAsync(node)
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setBackgroundImage).toHaveBeenCalledWith('3d/bg.png')
})
it('clears the background when the source background type is not image', async () => {
const source = makeSource({ backgroundInfo: { type: 'color' } })
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setBackgroundImage).toHaveBeenCalledWith('')
})
it('falls back to setLightIntensity(1) when the second light intensity is falsy', async () => {
const source = makeSource({ lightsIntensity: 0 })
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setLightIntensity).toHaveBeenCalledWith(1)
})
it('skips setFOV when the source camera is orthographic', async () => {
const source = makeSource({ cameraType: 'orthographic' })
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setFOV).not.toHaveBeenCalled()
})
it('always detaches the target gizmo at the start of the copy', async () => {
const source = makeSource({ currentModel: makeModel() })
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(state.gizmoManager.detach).toHaveBeenCalled()
})
it('calls setupForModel on the target gizmo with the freshly cloned model', async () => {
const clone = makeModel()
const source = makeSource({ currentModel: makeModel() })
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(state.gizmoManager.setupForModel).toHaveBeenCalledWith(clone)
})
})
})

View File

@@ -18,6 +18,7 @@ import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
import { comfyAPIPlugin } from './build/plugins'
import { CRITICAL_COVERAGE_DIRS } from './scripts/critical-coverage/criticalCoverageDirs'
dotenvConfig()
@@ -30,46 +31,6 @@ const DISABLE_TEMPLATES_PROXY = process.env.DISABLE_TEMPLATES_PROXY === 'true'
const GENERATE_SOURCEMAP = process.env.GENERATE_SOURCEMAP !== 'false'
const IS_STORYBOOK = process.env.npm_lifecycle_event === 'storybook'
const CRITICAL_COVERAGE_DIRS = [
'src/base',
'src/composables',
'src/core',
'src/lib/litegraph/src/node',
'src/lib/litegraph/src/subgraph',
'src/lib/litegraph/src/utils',
'src/platform/assets/composables',
'src/platform/assets/mappings',
'src/platform/assets/schemas',
'src/platform/assets/services',
'src/platform/assets/utils',
'src/platform/errorCatalog',
'src/platform/keybindings',
'src/platform/missingMedia',
'src/platform/missingModel',
'src/platform/navigation',
'src/platform/nodeReplacement',
'src/platform/remote',
'src/platform/remoteConfig',
'src/platform/secrets',
'src/platform/settings',
'src/platform/workflow',
'src/platform/workspace/api',
'src/platform/workspace/auth',
'src/platform/workspace/composables',
'src/platform/workspace/stores',
'src/platform/workspace/utils',
'src/schemas',
'src/scripts',
'src/services',
'src/stores',
'src/utils',
'src/workbench/extensions/manager/composables',
'src/workbench/extensions/manager/services',
'src/workbench/extensions/manager/stores',
'src/workbench/extensions/manager/utils',
'src/workbench/utils'
]
// A single glob key so vitest aggregates all critical dirs into one
// thresholds bucket instead of one bucket per glob
const CRITICAL_COVERAGE_GLOB = `{${CRITICAL_COVERAGE_DIRS.join(',')}}/**/*.{ts,vue}`